# Plan: SX-native engine tests (browser-independent)

## Goal

Move the host's *interactive* test coverage from Playwright (`.spec.js`, drives a real
Chromium) into **SX harness tests** that drive the hypermedia engine against a **mock
platform** — no browser. Reserve Playwright for the one irreducible real-browser fact:
"the WASM kernel actually compiles, boots, and loads modules content-addressed."

**Why (the principle):** the SX engine (`web/engine.sx` + `web/orchestration.sx`) has no
hard browser dependency — it talks to a *platform* (fetch, DOM ops, timers) that is
injected. The harness supplies a mock platform, so engine behaviour (fetch → swap →
DOM mutation) is asserted with zero browser. The same engine could therefore drive
*something else* (a server-side DOM, a native UI) — the SX tests prove that
independence by running without one. This is consistent with
`[[project_zero_dependencies]]` and `[[feedback_runtime_control]]` (build IN the runtime).

## Current state (2026-06-29)

- **Already SX:** the 272 host conformance tests (`lib/host/tests/*.sx`, `spec/harness.sx`
  mock-IO). The picker's *server contract* is SX too (`lib/host/tests/blog.sx`:
  `picker form declaratively wired`, `load-more sentinel`, `no-sentinel-on-short-page`).
- **Still Playwright (`.spec.js`):** `lib/host/playwright/relate-picker.spec.js` (7 tests)
  and `spa-check.spec.js` (4) — real-browser checks of populate / filter / paging /
  relate-delete / remove-button / boosted-nav / error-retry / WASM boot.

## Infrastructure that already exists (the enabler — verified)

- `spec/harness.sx` — `make-harness`, `default-platform` with **`:fetch` overridable**
  (`(fn (url &rest opts) {:status 200 :body "" :ok true})`), plus DOM ops, `:now`, etc.
- `web/harness-web.sx` — `(define-library (sx harness-web))` exports: `mock-element`,
  `mock-set-attr!`, `mock-append-child!`, `mock-get-attr`, `mock-add-listener!`,
  **`simulate-click` / `simulate-input` / `simulate-event`**, `assert-text`, `assert-attr`,
  `assert-class`, `assert-no-class`, `assert-child-count`, `assert-event-fired`,
  `make-web-harness`, render-audit helpers.
- `web/tests/` — existing SX engine tests: `test-orchestration.sx` (17 deftests),
  `test-forms.sx` (25), `test-swap-integration.sx` (43, mock-response → swap → assert),
  `test-engine.sx`, `test-handlers.sx`. **`test-swap-integration.sx` is the reference
  pattern** (it sets `_mock-body`/`_mock-headers`/`_mock-content-type`, drives a swap,
  asserts the result).
- Runner: `hosts/ocaml/bin/run_tests.ml` scans `spec/tests/`, `lib/tests/`, `web/tests/`
  and loads `harness-web.sx` + `harness-reactive.sx`. Run via the `sx_test host="ocaml"`
  MCP tool (or `./scripts/sx-build-all.sh`). JS runner: `hosts/javascript/run_tests.js`
  also loads the web harnesses.

## Phases

### Phase 0 — Proof of concept (small): one behavior, SX
Port **relate → delete row** to an SX harness test (new `web/tests/test-relate-picker.sx`):
1. Build a mock DOM: a `.rp-results` `<ul>` containing one candidate `<li id="cand-related-x">`
   with the relate `<form sx-post=/x/relate sx-target=#cand-related-x sx-swap=delete>`.
2. `process-elements` (or `bind-triggers`) the tree so the form's submit is bound.
3. Mock `:fetch` to return `{:status 200 :ok true :body ""}`.
4. `simulate-click` the button (or `simulate-event` "submit" on the form).
5. Assert the `<li>` is gone (`assert-child-count` results = 0).
This validates the **mock-DOM → execute-request → swap-dom-nodes** loop in SX end to end.
**If it reads cleanly, the rest is mechanical.**

### Phase 1 — Port the picker's interactive behaviors (medium)
Same file, more deftests, each = mock fetch + simulate + assert:
- **filter narrows**: `:fetch` returns N candidate rows for `q=...`; `simulate-input` the
  filter; assert child-count == N.
- **sentinel paging**: `:fetch` returns rows + a `<li class=rp-more sx-trigger=revealed>`;
  fire the revealed/intersect path; assert more rows appended, sentinel replaced.
- **load populate**: `load` trigger → fetch → assert results filled.
- **error/retry visible state**: `:fetch` rejects → assert `.sx-error` class added
  (`assert-class`), then succeeds → assert cleared.

### Phase 2 — Trim Playwright to a boot smoke (small)
Keep ONLY what needs a real browser in `relate-picker.spec.js` / `spa-check.spec.js`:
- WASM kernel compiles + boots (`data-sx-ready`).
- modules load **content-addressed** (`/sx/h/` fetches, 0 path `.sxbc`).
- one boosted nav swaps `#content`.
Delete the per-behavior browser tests now covered by SX. Net: ~2 browser tests + an
SX suite.

### Phase 3 — The engine drives the CONSOLE (the non-browser target)
The concrete "something else" is a **terminal / console platform**. This is the natural
sibling of the test harness: a harness test *asserts* the engine's output tree; the
console platform *renders* that same tree to text. Same platform abstraction — one
observes it, one draws it.

What it means concretely:
- **Platform ops → a console-backed element tree.** The engine only ever calls platform
  primitives: `dom-create-element`, `dom-append`, `dom-set-attr`, `dom-query` (by id, for
  `sx-target`), `dom-remove-child`, `dom-parent`, `morph-children`, `dom-listen`, `fetch`,
  `set-timeout`. Implement these against an in-memory tree of text nodes instead of the
  browser DOM. The mock DOM in `web/harness-web.sx` is ~90% of this already.
- **Render = print the tree as text** (ANSI/box-drawing) — a `render-to-console` mode
  alongside `render-to-html` / `render-to-dom` (see `spec/render.sx`'s mode table). The
  results `<ul>` becomes a list; `.sx-error` becomes a red line; the filter input is a
  text field.
- **Events = a TUI input loop.** Keypresses / selection map to `simulate-input` /
  `simulate-click` on the focused node — exactly the harness's `simulate-*`, but driven by
  a real keyboard instead of a test.
- **`fetch` stays HTTP** (the host already serves `text/sx` fragments + `relate-options`),
  or talks to a local store.

Payoff: the **same** `~relate-picker` — `sx-get`, debounced filter, `revealed` paging,
`sx-swap=delete`, `sx-error` retry — runs unchanged in a terminal. That is the proof that
the SX hypermedia engine is a *general* runtime, not a browser library: the browser is
just one platform binding, the console is another, the test harness is a third. Ambitious,
buildable, and the most convincing demonstration of the whole architecture
(`[[feedback_runtime_control]]`, `[[project_zero_dependencies]]`).

Sketch of work: (1) a `console-platform.sx` implementing the platform ops over a text
tree (fork `harness-web.sx`'s mock element), (2) a `render-to-console` mode in render.sx,
(3) a tiny input loop (raw-mode stdin → focus model → `simulate-*`), (4) run the host's
picker against it. Phase 1's SX tests become the regression suite for the console renderer
for free (they already drive the tree, just don't print it).

## Gaps & risks to resolve during Phase 0

- **Mock-DOM completeness:** `swap-dom-nodes` uses `morph-children`, `dom-replace-child`,
  `dom-insert-after/before/prepend/append`, `dom-remove-child`, `dom-parent`,
  `dom-first-child`, `dom-clone`, `dom-is-fragment?`. Confirm `harness-web`'s mock DOM
  implements (or can be extended for) these. `test-swap-integration.sx` already swaps, so
  most exist; check `delete`/`outerHTML`/fragment paths specifically.
- **fetch callback shape:** the engine's `fetch-request` calls back
  `(resp-ok status get-header text)`; the platform `:fetch` returns `{:status :body :ok}`.
  Confirm/adapt the bridge (see how `test-swap-integration.sx` feeds `_mock-body` etc.).
- **trigger binding without a browser:** `simulate-click` fires bound listeners — the form
  must be processed first (`process-elements` on the mock root, or bind directly).
- **component expansion:** `~relate-picker` need not be expanded for these tests — assert
  on the *rendered* candidate rows / form markup directly (build the mock DOM from the
  expanded HTML the server produces, which is already SX-testable server-side).

## Tracked loose ends (separate from this plan)
- **unrelate "clever" in-place delete** (just-the-row, no `#content` re-render): now that
  `bind-boost-form` is fixed the remove button works via a boosted POST→swap; the
  minimal-mutation version (sx-post + `sx-swap=delete` on the current-row) is a further
  refinement — earlier attempt didn't fire, revisit with the binding now understood.
- **`hs-repeat-times`** bytecode test (architecture worktree): harness `host-new` stub bug
  masks a pre-existing `beingTold` resume-env bug. See the diagnosis in this session.

## Progress (2026-06-29)

- **Phase 0 DONE** (commit 297bdc60) — `web/tests/test-relate-picker.sx`: relate→delete
  row drives the real engine (process-elements → submit → mock fetch → delete swap)
  against the OCaml runner's mock DOM, green. Mock-DOM completeness added to
  `run_tests.ml`: `NodeList.item(i)` (so `dom-query-all` iterates) + a `DOMParser`
  mock (so the empty-body `sx-swap=delete` HTML-response path works as in a browser).
- **Phase 1 DONE** (commit fe2da2d3) — same file, load / filter / paging / error-retry,
  5/5 green, zero harness noise. Modelled two browser natives the OCaml runner lacks:
  `observe-intersection` (a recording stub the test fires to simulate the sentinel
  scrolling into view) and synchronous-timer retry (stripped in the error test —
  backoff math is a `test-engine.sx` concern). Mock-DOM: `firstChild`/`lastChild`
  (so `children-to-fragment` drains a parsed fragment into innerHTML/outerHTML swaps;
  also repaired one pre-existing web test). No web-suite regressions.
  - **Key seam discovered:** a top-level `(define …)` override is seen by engine
    library functions ONLY when the symbol lives in a *different* library than the
    caller (cross-library late-binds through global; same-library resolves locally).
    `fetch-request` (boot-helpers) overrides fine from a test; `handle-retry`
    (orchestration, same lib as `do-fetch`) does NOT — hence the strip-attr approach.
  - **harness-web.sx is NOT loaded** by the OCaml runner (only the JS runner), and its
    assertions assume a different mock-element shape (`attrs`/`text`) than the OCaml
    mock DOM (`attributes`/`textContent`). Assert through the engine's own `dom-*`
    accessors instead.
- **Phase 2 DONE** (commit 98ff7a35) — Playwright trimmed 11 → 5 tests, both ephemeral
  suites green (run-spa-check 3/3, run-picker-check 2/2). Kept: WASM boot +
  content-addressed module loading (new `/sx/h/` assertion) + boosted nav swap +
  back/re-boost (spa-check); bind-boost-form remove button + picker re-bind after a
  boosted SPA nav (relate-picker). Deleted the populate/filter/paging/relate-delete/
  error-retry browser tests (now SX).
- **Phase 3 (stretch) — render slice DONE** (commit 16f90ffd) — `web/console-render.sx`:
  `render-to-console` walks a live DOM element tree through the engine's own `dom-*`
  accessors and prints it as terminal text (results `<ul>` → bulleted list, filter
  `<input>` → text field, `.rp-more` sentinel → `…` line, `.sx-error` → flagged line).
  Wired into the picker's engine tests so the SAME tree drives both the DOM assertion
  and the terminal output — Phase 1's suite is the console renderer's regression suite
  for free. Plus a `relate-picker:console` suite. 7/7 green.
  - **Remaining Phase 3 (future):** the live input loop — raw-mode stdin → focus model
    → `simulate-input`/`simulate-click` on the focused node — and full ANSI/box-drawing
    output. Not harness-testable (needs a real TTY), so it's a runtime/demo feature, not
    a test. The render step (the convincing half — "render = print the tree") is done;
    the engine→console *event* path reuses the same `simulate-*` the harness already
    drives. Class membership must read the live `classList` (`dom-has-class?`), not the
    static `class` attribute (the engine mutates classes through classList).

## Done-when
- [x] `web/tests/test-relate-picker.sx` covers populate / filter / paging / relate-delete /
  error-retry in SX, green under `sx_test host="ocaml"`.
- [x] Playwright trimmed to the boot smoke; suite still green.
- [~] (Stretch) the picker runs through a non-browser platform — render-to-console done
  (the engine's tree prints to a terminal); live TTY input loop is future work.
