diff --git a/hosts/ocaml/bin/run_tests.ml b/hosts/ocaml/bin/run_tests.ml
index 8a4e1d44..1b5658ac 100644
--- a/hosts/ocaml/bin/run_tests.ml
+++ b/hosts/ocaml/bin/run_tests.ml
@@ -2812,10 +2812,13 @@ let run_spec_tests env test_files =
| "insertAdjacentHTML" | "prepend" | "showModal" | "show" | "close"
| "getBoundingClientRect" | "getAnimations" | "scrollIntoView"
| "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
(match kids with c :: _ -> c | [] -> Nil)
- | "lastElementChild" ->
+ | "lastElementChild" | "lastChild" ->
let kids = match Hashtbl.find_opt d "children" with Some (List l) -> l | _ -> [] in
(match List.rev kids with c :: _ -> c | [] -> Nil)
| "nextElementSibling" | "nextSibling" ->
diff --git a/web/tests/test-relate-picker.sx b/web/tests/test-relate-picker.sx
index e275353c..52dcef82 100644
--- a/web/tests/test-relate-picker.sx
+++ b/web/tests/test-relate-picker.sx
@@ -10,9 +10,10 @@
;;
;; 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
-;; 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
-;; 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
;; ── mock fetch ──────────────────────────────────────────────────────
@@ -47,18 +48,30 @@
;; ── harness platform shims ──────────────────────────────────────────
;; 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
-;; chain). The picker is plain SX-htmx with no islands or reactive attrs, so every
-;; one of these is a no-op here. Defined at top level so orchestration's swap path
+;; chain). The picker is plain SX-htmx with no islands or reactive attrs, so each
+;; 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
;; 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
;; 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 sx-hydrate-elements (fn (root) nil))
(define sx-hydrate-islands (fn (root) 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 ────────────────────────────────────────────────
;; Build a detached-then-attached subtree from an HTML string and return the
;; container. Attaching to (dom-body) is required because resolve-target uses
@@ -76,33 +89,73 @@
(fn (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!
(fn (el type)
(host-call el "dispatchEvent" (host-new "Event" type))))
-;; Count candidate rows (the engine's own dom-query-all, the same call the
-;; picker uses) — asserts through the platform DOM API, not a private shape.
-(define count-rows
- (fn (container) (len (dom-query-all container "li"))))
+;; Candidate-row / sentinel counts via the engine's own dom-query-all — asserts
+;; through the platform DOM API, not a private shape. Sentinel rows carry .rp-more;
+;; candidate rows are the rest (mirrors the spec's `li:not(.rp-more)`).
+(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:
-;; an
wrapping a relate
"
+ ""
+ ""
+ "
"
+ "
")))
+
+;; One candidate row, as host/blog--picker-item renders it.
+(define row-html
+ (fn (slug kind cand title)
+ (str
+ "
"
+ "
"
+ ""
+ ""
+ ""
+ "
"
+ "
")))
+
+;; The "load more" sentinel, as host/blog--picker-more renders it.
+(define sentinel-html
+ (fn (slug kind offset)
+ (str
+ "
Loading more…
")))
+
+;; 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 ───────────────────────────────────
(defsuite
@@ -113,15 +166,117 @@
;; the AJAX relate returns an empty 200 (text/html); sx-swap=delete then
;; removes the candidate's own
— this is the host's real response.
(let
- ((root (mk-root (picker-row-html "item-07" "related" "Picker Item 07")))
+ ((root (mk-root (str "