Durable plan for the next step: drive the engine against the mock platform (spec/harness.sx :fetch + web/harness-web.sx simulate-click/DOM asserts), so fetch->swap->DOM behavior is tested without a browser — the same engine could drive a non-browser target. Phases: PoC (relate-delete), port the rest, trim Playwright to WASM-boot + content-addressed-load, stretch = non-browser renderer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.8 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.sxmock-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) andspa-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-platformwith:fetchoverridable ((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.sxis the reference pattern (it sets_mock-body/_mock-headers/_mock-content-type, drives a swap, asserts the result).- Runner:
hosts/ocaml/bin/run_tests.mlscansspec/tests/,lib/tests/,web/tests/and loadsharness-web.sx+harness-reactive.sx. Run via thesx_test host="ocaml"MCP tool (or./scripts/sx-build-all.sh). JS runner:hosts/javascript/run_tests.jsalso 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):
- 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>. process-elements(orbind-triggers) the tree so the form's submit is bound.- Mock
:fetchto return{:status 200 :ok true :body ""}. simulate-clickthe button (orsimulate-event"submit" on the form).- Assert the
<li>is gone (assert-child-countresults = 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:
:fetchreturns N candidate rows forq=...;simulate-inputthe filter; assert child-count == N. - sentinel paging:
:fetchreturns rows + a<li class=rp-more sx-trigger=revealed>; fire the revealed/intersect path; assert more rows appended, sentinel replaced. - load populate:
loadtrigger → fetch → assert results filled. - error/retry visible state:
:fetchrejects → assert.sx-errorclass 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 — Stretch: the engine drives a non-browser target ("something else")
The mock platform already is a non-browser target. This phase makes it a real alternative renderer (e.g. server-side DOM string-builder, or a native UI shim) and runs the same picker through it — concretely proving platform-independence. Optional / future.
Gaps & risks to resolve during Phase 0
- Mock-DOM completeness:
swap-dom-nodesusesmorph-children,dom-replace-child,dom-insert-after/before/prepend/append,dom-remove-child,dom-parent,dom-first-child,dom-clone,dom-is-fragment?. Confirmharness-web's mock DOM implements (or can be extended for) these.test-swap-integration.sxalready swaps, so most exist; checkdelete/outerHTML/fragment paths specifically. - fetch callback shape: the engine's
fetch-requestcalls back(resp-ok status get-header text); the platform:fetchreturns{:status :body :ok}. Confirm/adapt the bridge (see howtest-swap-integration.sxfeeds_mock-bodyetc.). - trigger binding without a browser:
simulate-clickfires bound listeners — the form must be processed first (process-elementson the mock root, or bind directly). - component expansion:
~relate-pickerneed 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
#contentre-render): now thatbind-boost-formis fixed the remove button works via a boosted POST→swap; the minimal-mutation version (sx-post +sx-swap=deleteon the current-row) is a further refinement — earlier attempt didn't fire, revisit with the binding now understood. hs-repeat-timesbytecode test (architecture worktree): harnesshost-newstub bug masks a pre-existingbeingToldresume-env bug. See the diagnosis in this session.
Done-when
web/tests/test-relate-picker.sxcovers populate / filter / paging / relate-delete / error-retry in SX, green undersx_test host="ocaml".- Playwright trimmed to the boot smoke; suite still green.
- (Stretch) the picker runs through a non-browser platform.