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

12 KiB

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.sxmake-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-pickersx-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

  • web/tests/test-relate-picker.sx covers populate / filter / paging / relate-delete / error-retry in SX, green under sx_test host="ocaml".
  • 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.