diff --git a/hosts/ocaml/bin/run_tests.ml b/hosts/ocaml/bin/run_tests.ml
index 1b5658ac..50decf7a 100644
--- a/hosts/ocaml/bin/run_tests.ml
+++ b/hosts/ocaml/bin/run_tests.ml
@@ -3696,6 +3696,7 @@ let run_spec_tests env test_files =
load_module "router.sx" web_dir;
load_module "deps.sx" web_dir;
load_module "orchestration.sx" web_dir;
+ load_module "console-render.sx" web_dir;
(* Library modules for lib/tests/ *)
load_module "bytecode.sx" lib_dir;
load_module "compiler.sx" lib_dir;
diff --git a/web/console-render.sx b/web/console-render.sx
new file mode 100644
index 00000000..299d14c6
--- /dev/null
+++ b/web/console-render.sx
@@ -0,0 +1,94 @@
+;; web/console-render.sx — render a live DOM element tree to terminal text.
+;;
+;; The SX hypermedia engine builds the SAME element tree whatever the platform: a
+;; browser PAINTS that tree, the test harness ASSERTS it, and this PRINTS it. Three
+;; bindings of one tree — the proof that the engine is a general runtime, not a
+;; browser library (Phase 3 of plans/sx-native-engine-tests.md).
+;;
+;; It reads the tree through the engine's own dom-* accessors, so it renders
+;; whatever the engine produced — e.g. the ~relate-picker after a load/filter/paging
+;; swap: the results
becomes a bulleted list, the filter a text field,
+;; the load-more sentinel a "…" line, and an .sx-error element a flagged line. The
+;; same render-to-console is the console platform's draw step; the picker's SX engine
+;; tests (web/tests/test-relate-picker.sx) double as its regression suite — they
+;; already drive the tree, this just prints it.
+
+;; class membership via the live classList (dom-has-class?), NOT the parsed `class`
+;; attribute — the engine adds .sx-error through classList, which leaves the static
+;; attribute untouched, so an attribute read would miss it.
+(define cr/has-class?
+ (fn (el cls) (dom-has-class? el cls)))
+
+;; The visible text of an element: its own textContent when it has no element
+;; children, else the concatenation of its children's text (the mock parser hangs
+;; leaf text on textContent, so this covers both row buttons and the sentinel).
+(define cr/text-of
+ (fn (el)
+ (if (nil? el)
+ ""
+ (let ((kids (dom-child-list el)))
+ (if (empty? kids)
+ (or (host-get el "textContent") "")
+ (let ((parts (map cr/text-of kids)))
+ (let ((joined (join "" parts)))
+ (if (= joined "")
+ (or (host-get el "textContent") "")
+ joined))))))))
+
+;; A
/ renders one line per
: a bullet for a candidate row, an ellipsis
+;; for the load-more sentinel (.rp-more).
+(define cr/list-lines
+ (fn (el)
+ (map
+ (fn (li)
+ (if (cr/has-class? li "rp-more")
+ (str " … " (cr/text-of li))
+ (str " • " (cr/text-of li))))
+ (dom-child-list el))))
+
+;; An renders as a labelled text field; hidden inputs aren't drawn.
+(define cr/input-line
+ (fn (el)
+ (let ((type (or (dom-get-attr el "type") "text")))
+ (if (= type "hidden")
+ ""
+ (let ((label (or (dom-get-attr el "placeholder") (dom-get-attr el "name") ""))
+ (val (or (host-get el "value") "")))
+ (str label ": [" (if (= val "") " " val) "]"))))))
+
+;; Flatten the rendered lines of an element's children, dropping blanks.
+(define cr/children-lines
+ (fn (el)
+ (let ((out (list)))
+ (for-each
+ (fn (kid)
+ (for-each
+ (fn (ln) (when (not (= ln "")) (append! out ln)))
+ (cr/lines kid)))
+ (dom-child-list el))
+ out)))
+
+;; Render one element to a list of text lines, tag-driven. An .sx-error element is
+;; flagged with a leading status line (the terminal's red line).
+(define cr/lines
+ (fn (el)
+ (if (nil? el)
+ (list)
+ (let ((tag (lower (dom-tag-name el)))
+ (err (cr/has-class? el "sx-error")))
+ (let
+ ((body
+ (cond
+ (or (= tag "ul") (= tag "ol")) (cr/list-lines el)
+ (= tag "input") (let ((l (cr/input-line el))) (if (= l "") (list) (list l)))
+ (= tag "button") (list (str "[ " (cr/text-of el) " ]"))
+ (or (= tag "h1") (= tag "h2") (= tag "h3")) (list (upper (cr/text-of el)))
+ :else (cr/children-lines el))))
+ (if err
+ (cons "✖ connection problem — retrying…" body)
+ body))))))
+
+;; render-to-console — the DOM-tree → terminal-text mode, alongside render-to-html /
+;; render-to-dom. Returns the rendered tree as a single newline-joined string.
+(define render-to-console
+ (fn (el) (join "\n" (cr/lines el))))
diff --git a/web/tests/test-relate-picker.sx b/web/tests/test-relate-picker.sx
index 52dcef82..e7927d50 100644
--- a/web/tests/test-relate-picker.sx
+++ b/web/tests/test-relate-picker.sx
@@ -117,7 +117,7 @@
" sx-swap=\"innerHTML\""
" sx-retry=\"exponential:1000:30000\">"
""
- ""
+ ""
"
"
"")))
@@ -200,6 +200,12 @@
(process-elements root)
(assert-true (> _mock-fetch-calls 0))
(assert-equal 5 (count-candidates results))
+ ;; Phase 3: the SAME populated tree renders to the console — the results
+ ;;
becomes a bulleted list of candidate titles. (Console renderer driven
+ ;; for free by the engine tree; web/console-render.sx.)
+ (let ((txt (render-to-console results)))
+ (assert-true (contains? txt "• Picker Item 0"))
+ (assert-true (contains? txt "• Picker Item 4")))
(clear-root! root))))
(defsuite
@@ -272,6 +278,9 @@
;; visible failure state: .sx-error lands on the picker form
(assert-true (dom-has-class? form "sx-error"))
(assert-equal 0 (count-candidates results))
+ ;; Phase 3: the console rendering of the errored picker shows the failure as
+ ;; a flagged line (the terminal's "red line") — same tree, different binding.
+ (assert-true (contains? (render-to-console form) "✖"))
;; recovery: the endpoint works again, the next input retries and the error
;; clears as the results populate
(set! _mock-fetch-fail false)
@@ -280,3 +289,41 @@
(assert-false (dom-has-class? form "sx-error"))
(assert-equal 3 (count-candidates results))
(clear-root! root))))
+
+;; ── Phase 3: the engine drives a non-browser target (the console) ───
+;; render-to-console (web/console-render.sx) prints the live engine tree as text.
+;; These assert the picker's terminal rendering directly on a built tree — the
+;; console platform's draw step, proven without a terminal.
+(defsuite
+ "relate-picker:console"
+ (deftest
+ "the picker form renders as a filter field over a bulleted candidate list"
+ (let
+ ((root (mk-root (str
+ "
")))
+ (form (dom-query ".relate-picker")))
+ (let ((txt (render-to-console form)))
+ ;; the text input becomes a labelled field (placeholder as the label)...
+ (assert-true (contains? txt "filter…: ["))
+ ;; ...the hidden kind input is not drawn...
+ (assert-false (contains? txt "related: ["))
+ ;; ...each candidate is a bullet, the sentinel an ellipsis line
+ (assert-true (contains? txt "• Picker Item 0"))
+ (assert-true (contains? txt "• Picker Item 1"))
+ (assert-true (contains? txt "… Loading more…")))
+ (clear-root! root)))
+ (deftest
+ "an empty results list renders no bullets"
+ (let
+ ((root (mk-root "
"))
+ (results (dom-query root ".rp-results"))) ;; scope to this root
+ (assert-equal "" (render-to-console results))
+ (clear-root! root))))