Files
rose-ash/plans/sx-native-engine-tests.md

172 lines
11 KiB
Markdown

# 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)** — the console platform — NOT STARTED.
## 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.