host/tests: Phase 1 — picker load/filter/paging/error-retry as SX engine tests
Port the rest of the relate-picker's interactive behaviours from Playwright into the SX harness, driving the real engine against the mock DOM: - load: the form's "load" trigger populates the results on first render - filter: a debounced "input" re-fetches and narrows the candidates - paging: revealing the load-more sentinel pages in the next page (outerHTML swap replaces the sentinel) - error-retry: a dropped fetch marks .sx-error, and the next request clears it Models two browser natives the OCaml runner lacks: observe-intersection (a recording stub the test fires to simulate the sentinel scrolling into view) and the synchronous-timer retry (stripped in the error test — backoff timing is a test-engine.sx concern; here we assert the visible state). Mock-DOM completeness (run_tests.ml): firstChild/lastChild on elements, so children-to-fragment can drain a parsed fragment into an innerHTML/outerHTML swap target. (Also repairs one pre-existing web test that needed firstChild.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2812,10 +2812,13 @@ let run_spec_tests env test_files =
|
|||||||
| "insertAdjacentHTML" | "prepend" | "showModal" | "show" | "close"
|
| "insertAdjacentHTML" | "prepend" | "showModal" | "show" | "close"
|
||||||
| "getBoundingClientRect" | "getAnimations" | "scrollIntoView"
|
| "getBoundingClientRect" | "getAnimations" | "scrollIntoView"
|
||||||
| "scrollTo" | "scroll" | "reset" -> Bool true
|
| "scrollTo" | "scroll" | "reset" -> Bool true
|
||||||
| "firstElementChild" ->
|
| "firstElementChild" | "firstChild" ->
|
||||||
|
(* the mock treats element children and child nodes alike, so
|
||||||
|
firstChild == firstElementChild — children-to-fragment walks
|
||||||
|
firstChild to drain a parsed fragment into a swap target. *)
|
||||||
let kids = match Hashtbl.find_opt d "children" with Some (List l) -> l | _ -> [] in
|
let kids = match Hashtbl.find_opt d "children" with Some (List l) -> l | _ -> [] in
|
||||||
(match kids with c :: _ -> c | [] -> Nil)
|
(match kids with c :: _ -> c | [] -> Nil)
|
||||||
| "lastElementChild" ->
|
| "lastElementChild" | "lastChild" ->
|
||||||
let kids = match Hashtbl.find_opt d "children" with Some (List l) -> l | _ -> [] in
|
let kids = match Hashtbl.find_opt d "children" with Some (List l) -> l | _ -> [] in
|
||||||
(match List.rev kids with c :: _ -> c | [] -> Nil)
|
(match List.rev kids with c :: _ -> c | [] -> Nil)
|
||||||
| "nextElementSibling" | "nextSibling" ->
|
| "nextElementSibling" | "nextSibling" ->
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
;;
|
;;
|
||||||
;; Pattern (mirrors test-swap-integration.sx's mock-response approach):
|
;; Pattern (mirrors test-swap-integration.sx's mock-response approach):
|
||||||
;; 1. build a mock DOM by setting innerHTML on a container attached to the body
|
;; 1. build a mock DOM by setting innerHTML on a container attached to the body
|
||||||
;; 2. (process-elements root) binds the engine's triggers (form submit etc.)
|
;; 2. (process-elements root) binds the engine's triggers (load / input / submit)
|
||||||
;; 3. override `fetch-request` to invoke the success/error callback with a
|
;; 3. override `fetch-request` to invoke the success/error callback with a
|
||||||
;; mocked response (status, content-type, body)
|
;; mocked response (status, content-type, body) — relate-options returns
|
||||||
|
;; HTML rows, so the body is HTML and the engine swaps via DOMParser
|
||||||
;; 4. fire the trigger (dispatch a DOM event) and assert the resulting DOM
|
;; 4. fire the trigger (dispatch a DOM event) and assert the resulting DOM
|
||||||
|
|
||||||
;; ── mock fetch ──────────────────────────────────────────────────────
|
;; ── mock fetch ──────────────────────────────────────────────────────
|
||||||
@@ -47,18 +48,30 @@
|
|||||||
;; ── harness platform shims ──────────────────────────────────────────
|
;; ── harness platform shims ──────────────────────────────────────────
|
||||||
;; Reactive hydration + island disposal live in web/boot.sx (the browser boot
|
;; Reactive hydration + island disposal live in web/boot.sx (the browser boot
|
||||||
;; module, not loaded by the test runner — it pulls the signals/adapter-dom boot
|
;; module, not loaded by the test runner — it pulls the signals/adapter-dom boot
|
||||||
;; chain). The picker is plain SX-htmx with no islands or reactive attrs, so every
|
;; chain). The picker is plain SX-htmx with no islands or reactive attrs, so each
|
||||||
;; one of these is a no-op here. Defined at top level so orchestration's swap path
|
;; of these is a no-op here. Defined at top level so orchestration's swap path
|
||||||
;; (post-swap -> sx-hydrate -> sx-hydrate-elements, dispose-islands-in) resolves
|
;; (post-swap -> sx-hydrate -> sx-hydrate-elements, dispose-islands-in) resolves
|
||||||
;; them by late binding through the global env — the same mechanism that lets the
|
;; them by late binding through the global env — the same mechanism that lets the
|
||||||
;; fetch-request override above take effect. With these in place post-swap runs to
|
;; fetch-request override above take effect. With these in place post-swap runs to
|
||||||
;; completion, including its final (process-elements root) that re-binds triggers
|
;; completion, including its final (process-elements root) that re-binds triggers
|
||||||
;; on swapped-in content — which the paging tests below depend on.
|
;; on swapped-in content — which the paging test below depends on.
|
||||||
(define dispose-islands-in (fn (root) nil))
|
(define dispose-islands-in (fn (root) nil))
|
||||||
(define sx-hydrate-elements (fn (root) nil))
|
(define sx-hydrate-elements (fn (root) nil))
|
||||||
(define sx-hydrate-islands (fn (root) nil))
|
(define sx-hydrate-islands (fn (root) nil))
|
||||||
(define run-post-render-hooks (fn () nil))
|
(define run-post-render-hooks (fn () nil))
|
||||||
|
|
||||||
|
;; observe-intersection is a platform NATIVE (registered by the browser's
|
||||||
|
;; sx-platform.js, absent in the OCaml test runner). The picker's "load more"
|
||||||
|
;; sentinel binds its `revealed` trigger through it. Model it as a recording stub:
|
||||||
|
;; binding the sentinel registers its reveal callback; the test fires it explicitly
|
||||||
|
;; to simulate the sentinel scrolling into view. Without this, process-elements
|
||||||
|
;; would throw on the sentinel's trigger and the paged-in content would never bind.
|
||||||
|
(define _last-reveal nil)
|
||||||
|
(define observe-intersection
|
||||||
|
(fn (el callback once delay) (set! _last-reveal callback)))
|
||||||
|
(define reset-reveal! (fn () (set! _last-reveal nil)))
|
||||||
|
(define fire-reveal! (fn () (when _last-reveal (_last-reveal))))
|
||||||
|
|
||||||
;; ── mock DOM helpers ────────────────────────────────────────────────
|
;; ── mock DOM helpers ────────────────────────────────────────────────
|
||||||
;; Build a detached-then-attached subtree from an HTML string and return the
|
;; Build a detached-then-attached subtree from an HTML string and return the
|
||||||
;; container. Attaching to (dom-body) is required because resolve-target uses
|
;; container. Attaching to (dom-body) is required because resolve-target uses
|
||||||
@@ -76,33 +89,73 @@
|
|||||||
(fn (container)
|
(fn (container)
|
||||||
(when container (dom-remove-child (dom-body) container))))
|
(when container (dom-remove-child (dom-body) container))))
|
||||||
|
|
||||||
;; Fire a (non-bubbling) DOM event of `type` on `el`, as the browser would.
|
;; Fire a (non-bubbling) DOM event of `type` on `el`, as the browser would. The
|
||||||
|
;; engine's triggers listen directly on the element carrying the verb (the form),
|
||||||
|
;; so the event need not bubble.
|
||||||
(define fire-event!
|
(define fire-event!
|
||||||
(fn (el type)
|
(fn (el type)
|
||||||
(host-call el "dispatchEvent" (host-new "Event" type))))
|
(host-call el "dispatchEvent" (host-new "Event" type))))
|
||||||
|
|
||||||
;; Count candidate rows (the engine's own dom-query-all, the same call the
|
;; Candidate-row / sentinel counts via the engine's own dom-query-all — asserts
|
||||||
;; picker uses) — asserts through the platform DOM API, not a private shape.
|
;; through the platform DOM API, not a private shape. Sentinel rows carry .rp-more;
|
||||||
(define count-rows
|
;; candidate rows are the rest (mirrors the spec's `li:not(.rp-more)`).
|
||||||
(fn (container) (len (dom-query-all container "li"))))
|
(define count-li (fn (c) (len (dom-query-all c "li"))))
|
||||||
|
(define count-more (fn (c) (len (dom-query-all c "li.rp-more"))))
|
||||||
|
(define count-candidates (fn (c) (- (count-li c) (count-more c))))
|
||||||
|
|
||||||
;; The picker's candidate row, exactly as host/blog--picker-item renders it:
|
;; ── markup builders (the expanded ~relate-picker, server-rendered) ──
|
||||||
;; an <li id="cand-KIND-SLUG"> wrapping a relate <form sx-post sx-target sx-swap=delete>.
|
;; The picker form, exactly as ~relate-picker (lib/host/sx/relate-picker.sx)
|
||||||
(define picker-row-html
|
;; expands: sx-get the candidate list, innerHTML-swap into its results <ul> on
|
||||||
(fn (slug kind title)
|
;; "load" and on a debounced "input".
|
||||||
|
(define picker-form-html
|
||||||
|
(fn (slug kind)
|
||||||
(str
|
(str
|
||||||
"<ul class=\"rp-results\">"
|
"<form class=\"relate-picker\" data-slug=\"" slug "\" data-kind=\"" kind "\""
|
||||||
"<li id=\"cand-" kind "-" slug "\">"
|
" sx-get=\"/" slug "/relate-options\""
|
||||||
"<form method=\"post\" action=\"/host/relate\""
|
" sx-trigger=\"input delay:200ms, load\""
|
||||||
" sx-post=\"/host/relate\""
|
" sx-target=\"#rp-" kind "-results\""
|
||||||
" sx-target=\"#cand-" kind "-" slug "\""
|
" sx-swap=\"innerHTML\""
|
||||||
" sx-swap=\"delete\">"
|
" sx-retry=\"exponential:1000:30000\">"
|
||||||
"<input type=\"hidden\" name=\"other\" value=\"" slug "\">"
|
"<input type=\"hidden\" name=\"kind\" value=\"" kind "\">"
|
||||||
"<input type=\"hidden\" name=\"kind\" value=\"" kind "\">"
|
"<input type=\"text\" name=\"q\" class=\"rp-filter\">"
|
||||||
"<button type=\"submit\">" title "</button>"
|
"<ul id=\"rp-" kind "-results\" class=\"rp-results\"></ul>"
|
||||||
"</form>"
|
"</form>")))
|
||||||
"</li>"
|
|
||||||
"</ul>")))
|
;; One candidate row, as host/blog--picker-item renders it.
|
||||||
|
(define row-html
|
||||||
|
(fn (slug kind cand title)
|
||||||
|
(str
|
||||||
|
"<li id=\"cand-" kind "-" cand "\">"
|
||||||
|
"<form method=\"post\" action=\"/" slug "/relate\""
|
||||||
|
" sx-post=\"/" slug "/relate\""
|
||||||
|
" sx-target=\"#cand-" kind "-" cand "\""
|
||||||
|
" sx-swap=\"delete\">"
|
||||||
|
"<input type=\"hidden\" name=\"other\" value=\"" cand "\">"
|
||||||
|
"<input type=\"hidden\" name=\"kind\" value=\"" kind "\">"
|
||||||
|
"<button type=\"submit\">" title "</button>"
|
||||||
|
"</form>"
|
||||||
|
"</li>")))
|
||||||
|
|
||||||
|
;; The "load more" sentinel, as host/blog--picker-more renders it.
|
||||||
|
(define sentinel-html
|
||||||
|
(fn (slug kind offset)
|
||||||
|
(str
|
||||||
|
"<li class=\"rp-more\""
|
||||||
|
" sx-get=\"/" slug "/relate-options?kind=" kind "&q=&offset=" offset "\""
|
||||||
|
" sx-trigger=\"revealed\""
|
||||||
|
" sx-swap=\"outerHTML\""
|
||||||
|
" sx-retry=\"exponential:1000:30000\">Loading more…</li>")))
|
||||||
|
|
||||||
|
;; A page of N candidate rows "cand-i" / "Item i" for i in [lo, hi).
|
||||||
|
(define rows-html
|
||||||
|
(fn (slug kind lo hi)
|
||||||
|
(let ((acc ""))
|
||||||
|
(let loop ((i lo))
|
||||||
|
(if (< i hi)
|
||||||
|
(do
|
||||||
|
(set! acc (str acc (row-html slug kind (str "item-" i) (str "Picker Item " i))))
|
||||||
|
(loop (+ i 1)))
|
||||||
|
acc)))))
|
||||||
|
|
||||||
;; ── Phase 0: relate -> delete row ───────────────────────────────────
|
;; ── Phase 0: relate -> delete row ───────────────────────────────────
|
||||||
(defsuite
|
(defsuite
|
||||||
@@ -113,15 +166,117 @@
|
|||||||
;; the AJAX relate returns an empty 200 (text/html); sx-swap=delete then
|
;; the AJAX relate returns an empty 200 (text/html); sx-swap=delete then
|
||||||
;; removes the candidate's own <li> — this is the host's real response.
|
;; removes the candidate's own <li> — this is the host's real response.
|
||||||
(let
|
(let
|
||||||
((root (mk-root (picker-row-html "item-07" "related" "Picker Item 07")))
|
((root (mk-root (str "<ul class=\"rp-results\">"
|
||||||
|
(row-html "host" "related" "item-07" "Picker Item 07")
|
||||||
|
"</ul>")))
|
||||||
(results (dom-query ".rp-results"))
|
(results (dom-query ".rp-results"))
|
||||||
(form (dom-query "form")))
|
(form (dom-query "form")))
|
||||||
(process-elements root)
|
(process-elements root)
|
||||||
;; one candidate row before
|
;; one candidate row before
|
||||||
(assert-equal 1 (count-rows results))
|
(assert-equal 1 (count-candidates results))
|
||||||
;; submit the relate form -> execute-request -> mock fetch -> delete swap
|
;; submit the relate form -> execute-request -> mock fetch -> delete swap
|
||||||
(fire-event! form "submit")
|
(fire-event! form "submit")
|
||||||
;; the fetch actually ran, and the row is gone
|
;; the fetch actually ran, and the row is gone
|
||||||
(assert-true (> _mock-fetch-calls 0))
|
(assert-true (> _mock-fetch-calls 0))
|
||||||
(assert-equal 0 (count-rows results))
|
(assert-equal 0 (count-candidates results))
|
||||||
|
(clear-root! root))))
|
||||||
|
|
||||||
|
;; ── Phase 1: load / filter / paging / error-retry ───────────────────
|
||||||
|
(defsuite
|
||||||
|
"relate-picker:load"
|
||||||
|
(deftest
|
||||||
|
"the load trigger populates the results on first render"
|
||||||
|
(reset-fetch-mock!)
|
||||||
|
(reset-reveal!)
|
||||||
|
;; one full page of candidates (no sentinel) returned for the load fetch
|
||||||
|
(set! _mock-fetch-body (rows-html "host" "related" 0 5))
|
||||||
|
(let
|
||||||
|
((root (mk-root (picker-form-html "host" "related")))
|
||||||
|
(results (dom-query "#rp-related-results")))
|
||||||
|
;; results empty before binding
|
||||||
|
(assert-equal 0 (count-candidates results))
|
||||||
|
;; process-elements binds the "load" trigger; the harness fires its
|
||||||
|
;; set-timeout synchronously, so the populate fetch runs here
|
||||||
|
(process-elements root)
|
||||||
|
(assert-true (> _mock-fetch-calls 0))
|
||||||
|
(assert-equal 5 (count-candidates results))
|
||||||
|
(clear-root! root))))
|
||||||
|
|
||||||
|
(defsuite
|
||||||
|
"relate-picker:filter"
|
||||||
|
(deftest
|
||||||
|
"typing in the filter re-fetches and narrows the candidates"
|
||||||
|
(reset-fetch-mock!)
|
||||||
|
(reset-reveal!)
|
||||||
|
;; load shows 5 candidates...
|
||||||
|
(set! _mock-fetch-body (rows-html "host" "related" 0 5))
|
||||||
|
(let
|
||||||
|
((root (mk-root (picker-form-html "host" "related")))
|
||||||
|
(results (dom-query "#rp-related-results"))
|
||||||
|
(form (dom-query ".relate-picker"))
|
||||||
|
(filter (dom-query ".rp-filter")))
|
||||||
|
(process-elements root)
|
||||||
|
(assert-equal 5 (count-candidates results))
|
||||||
|
;; ...the filter narrows to a single match
|
||||||
|
(set! _mock-fetch-body (row-html "host" "related" "item-13" "Picker Item 13"))
|
||||||
|
(dom-set-attr filter "value" "Item 13")
|
||||||
|
;; debounced "input" trigger (delay fires synchronously in the harness)
|
||||||
|
(fire-event! form "input")
|
||||||
|
(assert-equal 1 (count-candidates results))
|
||||||
|
(clear-root! root))))
|
||||||
|
|
||||||
|
(defsuite
|
||||||
|
"relate-picker:paging"
|
||||||
|
(deftest
|
||||||
|
"revealing the load-more sentinel pages in the next page and replaces it"
|
||||||
|
(reset-fetch-mock!)
|
||||||
|
(reset-reveal!)
|
||||||
|
;; page 1: a full page of rows FOLLOWED BY the load-more sentinel
|
||||||
|
(set! _mock-fetch-body (str (rows-html "host" "related" 0 20)
|
||||||
|
(sentinel-html "host" "related" 20)))
|
||||||
|
(let
|
||||||
|
((root (mk-root (picker-form-html "host" "related")))
|
||||||
|
(results (dom-query "#rp-related-results")))
|
||||||
|
(process-elements root)
|
||||||
|
;; first page populated, sentinel present
|
||||||
|
(assert-equal 20 (count-candidates results))
|
||||||
|
(assert-equal 1 (count-more results))
|
||||||
|
;; page 2: the last page — more rows, NO sentinel
|
||||||
|
(set! _mock-fetch-body (rows-html "host" "related" 20 25))
|
||||||
|
;; the sentinel scrolling into view fires its `revealed` trigger
|
||||||
|
(fire-reveal!)
|
||||||
|
;; the new rows are appended and the sentinel (swapped outerHTML) is gone
|
||||||
|
(assert-equal 25 (count-candidates results))
|
||||||
|
(assert-equal 0 (count-more results))
|
||||||
|
(clear-root! root))))
|
||||||
|
|
||||||
|
(defsuite
|
||||||
|
"relate-picker:error-retry"
|
||||||
|
(deftest
|
||||||
|
"a dropped fetch shows .sx-error, and the next success clears it"
|
||||||
|
(reset-fetch-mock!)
|
||||||
|
(reset-reveal!)
|
||||||
|
;; the candidate fetch drops on load
|
||||||
|
(set! _mock-fetch-fail true)
|
||||||
|
(let
|
||||||
|
((root (mk-root (picker-form-html "host" "related")))
|
||||||
|
(results (dom-query "#rp-related-results"))
|
||||||
|
(form (dom-query ".relate-picker")))
|
||||||
|
;; sx-retry self-heals via exponential backoff on set-timeout — which fires
|
||||||
|
;; SYNCHRONOUSLY in the harness, so a retry against a still-failing mock would
|
||||||
|
;; recurse forever. Strip it: we assert the VISIBLE error state and that the
|
||||||
|
;; next *request* (the user retyping) clears it. The backoff math itself is
|
||||||
|
;; covered by test-engine.sx (parse-retry-spec / next-retry-ms).
|
||||||
|
(dom-remove-attr form "sx-retry")
|
||||||
|
(process-elements root)
|
||||||
|
;; visible failure state: .sx-error lands on the picker form
|
||||||
|
(assert-true (dom-has-class? form "sx-error"))
|
||||||
|
(assert-equal 0 (count-candidates results))
|
||||||
|
;; recovery: the endpoint works again, the next input retries and the error
|
||||||
|
;; clears as the results populate
|
||||||
|
(set! _mock-fetch-fail false)
|
||||||
|
(set! _mock-fetch-body (rows-html "host" "related" 0 3))
|
||||||
|
(fire-event! form "input")
|
||||||
|
(assert-false (dom-has-class? form "sx-error"))
|
||||||
|
(assert-equal 3 (count-candidates results))
|
||||||
(clear-root! root))))
|
(clear-root! root))))
|
||||||
|
|||||||
Reference in New Issue
Block a user