From be84246961bd4ac18791658e03592126898892a1 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 18 Apr 2026 20:46:01 +0000 Subject: [PATCH] HS parser/compiler/mock: fix 31 test failures across 7 issues Parser: - Relax (number? v) to v in parse-one-transition so (expr)unit works - Add (match-kw "then") before parse-cmd-list in parse-for-cmd - Handle "indexed by" syntax alongside "index" in for loops - Add "indexed" to hs-keywords to prevent unit-suffix consumption Compiler: - Use map-indexed instead of for-each for indexed for-loops Test generator: - Preserve \" escapes in process_hs_val via placeholder/restore Mock DOM: - Coerce insertAdjacentHTML values via dom_stringify (match browser) Co-Authored-By: Claude Opus 4.6 (1M context) --- hosts/ocaml/bin/run_tests.ml | 5 +- lib/hyperscript/compiler.sx | 4 +- lib/hyperscript/parser.sx | 8 +- lib/hyperscript/tokenizer.sx | 1 + spec/tests/test-hyperscript-behavioral.sx | 130 +++++---- tests/playwright/generate-sx-tests.py | 307 +++++++++++++++++++++- 6 files changed, 379 insertions(+), 76 deletions(-) diff --git a/hosts/ocaml/bin/run_tests.ml b/hosts/ocaml/bin/run_tests.ml index d8c7f544..14e2563e 100644 --- a/hosts/ocaml/bin/run_tests.ml +++ b/hosts/ocaml/bin/run_tests.ml @@ -1985,9 +1985,10 @@ let run_spec_tests env test_files = Hashtbl.replace r "right" (Number 100.0); Hashtbl.replace r "bottom" (Number 100.0); Dict r | "insertAdjacentHTML" -> - (* Simplified: just append text to innerHTML *) + (* Simplified: coerce value to string and append to innerHTML *) (match rest with - | [String _pos; String html] -> + | [String _pos; value] -> + let html = match dom_stringify value with String s -> s | _ -> "" in let cur = match Hashtbl.find_opt d "innerHTML" with Some (String s) -> s | _ -> "" in Hashtbl.replace d "innerHTML" (String (cur ^ html)); Nil | _ -> Nil) diff --git a/lib/hyperscript/compiler.sx b/lib/hyperscript/compiler.sx index 3ae4c062..85148e37 100644 --- a/lib/hyperscript/compiler.sx +++ b/lib/hyperscript/compiler.sx @@ -267,10 +267,10 @@ (if (and (> (len ast) 4) (= (nth ast 4) :index)) (list - (quote for-each) + (quote map-indexed) (list (quote fn) - (list (make-symbol var-name) (make-symbol (nth ast 5))) + (list (make-symbol (nth ast 5)) (make-symbol var-name)) body) collection) (list diff --git a/lib/hyperscript/parser.sx b/lib/hyperscript/parser.sx index 8e5d3607..8bb9546a 100644 --- a/lib/hyperscript/parser.sx +++ b/lib/hyperscript/parser.sx @@ -1223,10 +1223,10 @@ (let ((prop (cond ((= (tp-type) "style") (get (adv!) "value")) ((= (tp-val) "my") (do (adv!) (if (= (tp-type) "style") (get (adv!) "value") (get (adv!) "value")))) (true (get (adv!) "value"))))) (let - ((from-val (if (match-kw "from") (let ((v (parse-atom))) (if (and (number? v) (= (tp-type) "ident") (not (hs-keyword? (tp-val)))) (let ((unit (get (adv!) "value"))) (list (quote string-postfix) v unit)) v)) nil))) + ((from-val (if (match-kw "from") (let ((v (parse-atom))) (if (and v (= (tp-type) "ident") (not (hs-keyword? (tp-val)))) (let ((unit (get (adv!) "value"))) (list (quote string-postfix) v unit)) v)) nil))) (expect-kw! "to") (let - ((value (let ((v (parse-atom))) (if (and (number? v) (= (tp-type) "ident") (not (hs-keyword? (tp-val)))) (let ((unit (get (adv!) "value"))) (list (quote string-postfix) v unit)) v)))) + ((value (let ((v (parse-atom))) (if (and v (= (tp-type) "ident") (not (hs-keyword? (tp-val)))) (let ((unit (get (adv!) "value"))) (list (quote string-postfix) v unit)) v)))) (let ((dur (if (match-kw "over") (let ((v (parse-atom))) (if (and (number? v) (= (tp-type) "ident") (not (hs-keyword? (tp-val)))) (let ((unit (get (adv!) "value"))) (list (quote string-postfix) v unit)) v)) nil))) (let @@ -1521,9 +1521,9 @@ (let ((collection (parse-expr))) (let - ((idx (if (match-kw "index") (let ((iname (tp-val))) (adv!) iname) nil))) + ((idx (cond ((match-kw "index") (let ((iname (tp-val))) (adv!) iname)) ((match-kw "indexed") (do (match-kw "by") (let ((iname (tp-val))) (adv!) iname))) (true nil)))) (let - ((body (parse-cmd-list))) + ((body (do (match-kw "then") (parse-cmd-list)))) (match-kw "end") (if idx diff --git a/lib/hyperscript/tokenizer.sx b/lib/hyperscript/tokenizer.sx index 8c473703..2f7707d7 100644 --- a/lib/hyperscript/tokenizer.sx +++ b/lib/hyperscript/tokenizer.sx @@ -104,6 +104,7 @@ "detail" "sender" "index" + "indexed" "increment" "decrement" "append" diff --git a/spec/tests/test-hyperscript-behavioral.sx b/spec/tests/test-hyperscript-behavioral.sx index 354a536d..026e64b1 100644 --- a/spec/tests/test-hyperscript-behavioral.sx +++ b/spec/tests/test-hyperscript-behavioral.sx @@ -638,10 +638,11 @@ (deftest "can toggle between two attribute values" (hs-cleanup!) (let ((_el-div (dom-create-element "div"))) - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-div "_" "\\\"on") (dom-set-attr _el-div "data-state" "active") ;; SKIP attr [@data-state (contains special chars) (dom-append (dom-body) _el-div) + (hs-activate! _el-div) (dom-dispatch _el-div "click" nil) (assert= (dom-get-attr _el-div "data-state") "inactive") (dom-dispatch _el-div "click" nil) @@ -650,11 +651,12 @@ (deftest "can toggle between different attributes" (hs-cleanup!) (let ((_el-div (dom-create-element "div"))) - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-div "_" "\\\"on") (dom-set-attr _el-div "enabled" "true") ;; SKIP attr [@enabled (contains special chars) ;; SKIP attr [@disabled (contains special chars) (dom-append (dom-body) _el-div) + (hs-activate! _el-div) (dom-dispatch _el-div "click" nil) (assert= (dom-get-attr _el-div "disabled") "true") (dom-dispatch _el-div "click" nil) @@ -722,9 +724,10 @@ (deftest "can toggle *display between two values" (hs-cleanup!) (let ((_el-div (dom-create-element "div"))) - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-div "_" "\\\"on") (dom-set-attr _el-div "style" "display:none") (dom-append (dom-body) _el-div) + (hs-activate! _el-div) (dom-dispatch _el-div "click" nil) (assert= (dom-get-style _el-div "display") "flex") (dom-dispatch _el-div "click" nil) @@ -733,9 +736,10 @@ (deftest "can toggle *opacity between three values" (hs-cleanup!) (let ((_el-div (dom-create-element "div"))) - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-div "_" "\\\"on") (dom-set-attr _el-div "style" "opacity:0") (dom-append (dom-body) _el-div) + (hs-activate! _el-div) (dom-dispatch _el-div "click" nil) (assert= (dom-get-style _el-div "opacity") "0.5") (dom-dispatch _el-div "click" nil) @@ -746,8 +750,9 @@ (deftest "can toggle a global variable between two values" (hs-cleanup!) (let ((_el-div (dom-create-element "div"))) - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-div "_" "\\\"on") (dom-append (dom-body) _el-div) + (hs-activate! _el-div) (dom-dispatch _el-div "click" nil) (dom-dispatch _el-div "click" nil) (dom-dispatch _el-div "click" nil) @@ -755,8 +760,9 @@ (deftest "can toggle a global variable between three values" (hs-cleanup!) (let ((_el-div (dom-create-element "div"))) - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-div "_" "\\\"on") (dom-append (dom-body) _el-div) + (hs-activate! _el-div) (dom-dispatch _el-div "click" nil) (dom-dispatch _el-div "click" nil) (dom-dispatch _el-div "click" nil) @@ -1351,7 +1357,7 @@ (deftest "properly processes hyperscript in new content in a symbol write" (hs-cleanup!) (let ((_el-div (dom-create-element "div"))) - (dom-set-attr _el-div "_" "on click put \"\" into me") + (dom-set-attr _el-div "_" "on click put \"\" into me") (dom-append (dom-body) _el-div) (hs-activate! _el-div) (dom-dispatch _el-div "click" nil) @@ -1362,7 +1368,7 @@ (hs-cleanup!) (let ((_el-d1 (dom-create-element "div"))) (dom-set-attr _el-d1 "id" "d1") - (dom-set-attr _el-d1 "_" "on click put \"\" into ") + (dom-set-attr _el-d1 "_" "on click put \"\" into ") (dom-append (dom-body) _el-d1) (hs-activate! _el-d1) (dom-dispatch _el-d1 "click" nil) @@ -1373,7 +1379,7 @@ (hs-cleanup!) (let ((_el-d1 (dom-create-element "div"))) (dom-set-attr _el-d1 "id" "d1") - (dom-set-attr _el-d1 "_" "on click put \"\" before me") + (dom-set-attr _el-d1 "_" "on click put \"\" before me") (dom-append (dom-body) _el-d1) (hs-activate! _el-d1) (dom-dispatch _el-d1 "click" nil) @@ -1384,7 +1390,7 @@ (hs-cleanup!) (let ((_el-d1 (dom-create-element "div"))) (dom-set-attr _el-d1 "id" "d1") - (dom-set-attr _el-d1 "_" "on click put \"\" at the start of me") + (dom-set-attr _el-d1 "_" "on click put \"\" at the start of me") (dom-append (dom-body) _el-d1) (hs-activate! _el-d1) (dom-dispatch _el-d1 "click" nil) @@ -1395,7 +1401,7 @@ (hs-cleanup!) (let ((_el-d1 (dom-create-element "div"))) (dom-set-attr _el-d1 "id" "d1") - (dom-set-attr _el-d1 "_" "on click put \"\" at the end of me") + (dom-set-attr _el-d1 "_" "on click put \"\" at the end of me") (dom-append (dom-body) _el-d1) (hs-activate! _el-d1) (dom-dispatch _el-d1 "click" nil) @@ -1406,7 +1412,7 @@ (hs-cleanup!) (let ((_el-d1 (dom-create-element "div"))) (dom-set-attr _el-d1 "id" "d1") - (dom-set-attr _el-d1 "_" "on click put \"\" after me") + (dom-set-attr _el-d1 "_" "on click put \"\" after me") (dom-append (dom-body) _el-d1) (hs-activate! _el-d1) (dom-dispatch _el-d1 "click" nil) @@ -2047,7 +2053,7 @@ (deftest "for loop over undefined skips without error" (hs-cleanup!) (let ((_el-div (dom-create-element "div"))) - (dom-set-attr _el-div "_" "on click repeat for x in doesNotExist put x at end of me end put \"done\" into me") + (dom-set-attr _el-div "_" "on click repeat for x in doesNotExist put x at end of me end put \\\"done\\\" into me") (dom-append (dom-body) _el-div) (hs-activate! _el-div) (dom-dispatch _el-div "click" nil) @@ -2704,9 +2710,10 @@ (deftest "can transition on query ref with of syntax" (hs-cleanup!) (let ((_el-div (dom-create-element "div")) (_el-span (dom-create-element "span"))) - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-div "_" "\\\"on") (dom-append (dom-body) _el-div) (dom-append (dom-body) _el-span) + (hs-activate! _el-div) (dom-dispatch _el-div "click" nil) (assert= (dom-get-style _el-span "width") "100px") )) @@ -2980,7 +2987,7 @@ (deftest "throws on non-2xx response by default" (hs-cleanup!) (let ((_el-div (dom-create-element "div"))) - (dom-set-attr _el-div "_" "on click fetch /test catch e put \"caught\" into me") + (dom-set-attr _el-div "_" "on click fetch /test catch e put \\\"caught\\\" into me") (dom-append (dom-body) _el-div) (hs-activate! _el-div) (dom-dispatch _el-div "click" nil) @@ -3016,8 +3023,9 @@ (deftest "Response can be converted to JSON via as JSON" (hs-cleanup!) (let ((_el-div (dom-create-element "div"))) - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-div "_" "\\\"on") (dom-append (dom-body) _el-div) + (hs-activate! _el-div) (dom-dispatch _el-div "click" nil) (assert= (dom-text-content _el-div) "Joe") )) @@ -4184,7 +4192,7 @@ (hs-cleanup!) (let ((_el-d1 (dom-create-element "div"))) (dom-set-attr _el-d1 "id" "d1") - (dom-set-attr _el-d1 "_" "on myEvent(foo) if foo put foo into me else put \"no-detail\" into me") + (dom-set-attr _el-d1 "_" "on myEvent(foo) if foo put foo into me else put \\\"no-detail\\\" into me") (dom-append (dom-body) _el-d1) (hs-activate! _el-d1) )) @@ -4203,7 +4211,7 @@ (deftest "caught exceptions do not trigger 'exception' event" (hs-cleanup!) (let ((_el-button (dom-create-element "button"))) - (dom-set-attr _el-button "_" "on click put \"foo\" into me then throw \"bar\" catch e log e on exception(error) put error into me") + (dom-set-attr _el-button "_" "on click put \\\"foo\\\" into me then throw \\\"bar\\\" catch e log e on exception(error) put error into me") (dom-append (dom-body) _el-button) (hs-activate! _el-button) (dom-dispatch _el-button "click" nil) @@ -4212,7 +4220,7 @@ (deftest "rethrown exceptions trigger 'exception' event" (hs-cleanup!) (let ((_el-button (dom-create-element "button"))) - (dom-set-attr _el-button "_" "on click put \"foo\" into me then throw \"bar\" catch e throw e on exception(error) put error into me") + (dom-set-attr _el-button "_" "on click put \\\"foo\\\" into me then throw \\\"bar\\\" catch e throw e on exception(error) put error into me") (dom-append (dom-body) _el-button) (hs-activate! _el-button) (dom-dispatch _el-button "click" nil) @@ -4221,7 +4229,7 @@ (deftest "can ignore when target doesn\'t exist" (hs-cleanup!) (let ((_el-div (dom-create-element "div"))) - (dom-set-attr _el-div "_" "on click from #doesntExist then throw \"bar\" on click put \"clicked\" into me") + (dom-set-attr _el-div "_" "on click from #doesntExist then throw \\\"bar\\\" on click put \\\"clicked\\\" into me") (dom-append (dom-body) _el-div) (hs-activate! _el-div) (dom-dispatch _el-div "click" nil) @@ -4423,7 +4431,7 @@ (deftest "prompts and puts result in it" (hs-cleanup!) (let ((_el-button (dom-create-element "button")) (_el-out (dom-create-element "div"))) - (dom-set-attr _el-button "_" "on click ask \"What is your name?\" then put it into #out") + (dom-set-attr _el-button "_" "on click ask \\\"What is your name?\\\" then put it into #out") (dom-set-inner-html _el-button "Ask") (dom-set-attr _el-out "id" "out") (dom-append (dom-body) _el-button) @@ -4435,7 +4443,7 @@ (deftest "returns null on cancel" (hs-cleanup!) (let ((_el-button (dom-create-element "button")) (_el-out (dom-create-element "div"))) - (dom-set-attr _el-button "_" "on click ask \"Name?\" then put it into #out") + (dom-set-attr _el-button "_" "on click ask \\\"Name?\\\" then put it into #out") (dom-set-inner-html _el-button "Ask") (dom-set-attr _el-out "id" "out") (dom-append (dom-body) _el-button) @@ -4447,7 +4455,7 @@ (deftest "shows an alert" (hs-cleanup!) (let ((_el-button (dom-create-element "button")) (_el-out (dom-create-element "div"))) - (dom-set-attr _el-button "_" "on click answer \"Hello!\" then put \"done\" into #out") + (dom-set-attr _el-button "_" "on click answer \\\"Hello!\\\" then put \\\"done\\\" into #out") (dom-set-inner-html _el-button "Go") (dom-set-attr _el-out "id" "out") (dom-append (dom-body) _el-button) @@ -4459,7 +4467,7 @@ (deftest "confirm returns first choice on OK" (hs-cleanup!) (let ((_el-button (dom-create-element "button")) (_el-out (dom-create-element "div"))) - (dom-set-attr _el-button "_" "on click answer \"Save?\" with \"Yes\" or \"No\" then put it into #out") + (dom-set-attr _el-button "_" "on click answer \\\"Save?\\\" with \\\"Yes\\\" or \\\"No\\\" then put it into #out") (dom-set-inner-html _el-button "Go") (dom-set-attr _el-out "id" "out") (dom-append (dom-body) _el-button) @@ -4471,7 +4479,7 @@ (deftest "confirm returns second choice on cancel" (hs-cleanup!) (let ((_el-button (dom-create-element "button")) (_el-out (dom-create-element "div"))) - (dom-set-attr _el-button "_" "on click answer \"Save?\" with \"Yes\" or \"No\" then put it into #out") + (dom-set-attr _el-button "_" "on click answer \\\"Save?\\\" with \\\"Yes\\\" or \\\"No\\\" then put it into #out") (dom-set-inner-html _el-button "Go") (dom-set-attr _el-out "id" "out") (dom-append (dom-body) _el-button) @@ -4850,7 +4858,7 @@ (deftest "can parse go to with string URL" (hs-cleanup!) (let ((_el-div (dom-create-element "div"))) - (dom-set-attr _el-div "_" "on click go to \"#test-hash\"") + (dom-set-attr _el-div "_" "on click go to \\\"#test-hash\\\"") (dom-append (dom-body) _el-div) (hs-activate! _el-div) )) @@ -4946,11 +4954,12 @@ (dom-set-attr _el-outer "id" "outer") (dom-set-attr _el-outer "_" "on click add .outer-clicked") (dom-set-attr _el-inner "id" "inner") - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-inner "_" "\\\"on") (dom-set-inner-html _el-inner "click me") (dom-append (dom-body) _el-outer) (dom-append _el-outer _el-inner) (hs-activate! _el-outer) + (hs-activate! _el-inner) (dom-dispatch (dom-query-by-id "inner") "click" nil) (assert (not (dom-has-class? (dom-query-by-id "outer") "outer-clicked"))) (assert (dom-has-class? (dom-query-by-id "inner") "continued")) @@ -5036,7 +5045,7 @@ (dom-set-attr _el-target "id" "target") (dom-set-inner-html _el-span "first") (dom-set-attr _el-target2 "id" "target") - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-target2 "_" "\\\"on") (dom-set-inner-html _el-span3 "first") (dom-set-inner-html _el-span4 "second") (dom-append (dom-body) _el-target) @@ -5044,6 +5053,7 @@ (dom-append (dom-body) _el-target2) (dom-append _el-target2 _el-span3) (dom-append _el-target2 _el-span4) + (hs-activate! _el-target2) (dom-dispatch (dom-query-by-id "go") "click" nil) )) (deftest "morph removes old children" @@ -5053,13 +5063,14 @@ (dom-set-inner-html _el-span "first") (dom-set-inner-html _el-span2 "second") (dom-set-attr _el-target3 "id" "target") - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-target3 "_" "\\\"on") (dom-set-inner-html _el-span4 "first") (dom-append (dom-body) _el-target) (dom-append _el-target _el-span) (dom-append _el-target _el-span2) (dom-append (dom-body) _el-target3) (dom-append _el-target3 _el-span4) + (hs-activate! _el-target3) (dom-dispatch (dom-query-by-id "go") "click" nil) )) (deftest "morph initializes hyperscript on new elements" @@ -5068,7 +5079,7 @@ (dom-set-attr _el-target "id" "target") (dom-set-inner-html _el-p "old") (dom-set-attr _el-target2 "id" "target") - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-target2 "_" "\\\"on") (dom-set-attr _el-inner "id" "inner") ;; HS source has bare quotes or embedded HTML (dom-set-inner-html _el-inner "new") @@ -5076,6 +5087,7 @@ (dom-append _el-target _el-p) (dom-append (dom-body) _el-target2) (dom-append _el-target2 _el-inner) + (hs-activate! _el-target2) (dom-dispatch (dom-query-by-id "go") "click" nil) (assert= (dom-text-content (dom-query-by-id "inner")) "new") (dom-dispatch (dom-query-by-id "inner") "click" nil) @@ -5086,7 +5098,7 @@ (let ((_el-target (dom-create-element "div")) (_el-child (dom-create-element "div")) (_el-button (dom-create-element "button"))) (dom-set-attr _el-target "id" "target") (dom-set-attr _el-child "id" "child") - (dom-set-attr _el-child "_" "on click put \"alive\" into me") + (dom-set-attr _el-child "_" "on click put \\\"alive\\\" into me") (dom-set-inner-html _el-child "child") ;; HS source has bare quotes or embedded HTML (dom-set-inner-html _el-button "go") @@ -5132,7 +5144,7 @@ (dom-set-attr _el-target "id" "target") (dom-set-inner-html _el-target "original") (dom-set-attr _el-go "id" "go") - (dom-set-attr _el-go "_" "on click set content to \"
morphed
\" then morph #target to content") + (dom-set-attr _el-go "_" "on click set content to \\\"
morphed
\\\" then morph #target to content") (dom-set-inner-html _el-go "go") (dom-append (dom-body) _el-target) (dom-append (dom-body) _el-go) @@ -7255,7 +7267,7 @@ (let ((_el-target (dom-create-element "div")) (_el-button (dom-create-element "button"))) (dom-set-attr _el-target "id" "target") (dom-set-inner-html _el-target "old") - (dom-set-attr _el-button "_" "on click make a then put \"moved\" into it then set #target to it") + (dom-set-attr _el-button "_" "on click make a then put \\\"moved\\\" into it then set #target to it") (dom-set-inner-html _el-button "go") (dom-append (dom-body) _el-target) (dom-append (dom-body) _el-button) @@ -7343,7 +7355,7 @@ (let ((_el-target (dom-create-element "div")) (_el-button (dom-create-element "button"))) (dom-set-attr _el-target "id" "target") (dom-set-inner-html _el-target "old") - (dom-set-attr _el-button "_" "on click put \"new\" into #target") + (dom-set-attr _el-button "_" "on click put \\\"new\\\" into #target") (dom-set-inner-html _el-button "go") (dom-append (dom-body) _el-target) (dom-append (dom-body) _el-button) @@ -7464,15 +7476,17 @@ (dom-set-inner-html _el-span2 "B") (dom-add-class _el-span3 "a") (dom-set-inner-html _el-span3 "C") - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-button "_" "\\\"on") (dom-set-attr _el-b2 "id" "b2") - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-b2 "_" "\\\"on") (dom-append (dom-body) _el-box) (dom-append _el-box _el-span) (dom-append _el-box _el-span2) (dom-append _el-box _el-span3) (dom-append (dom-body) _el-button) (dom-append (dom-body) _el-b2) + (hs-activate! _el-button) + (hs-activate! _el-b2) (dom-dispatch _el-button "click" nil) (assert= (dom-text-content _el-button) "2") (dom-dispatch (dom-query-by-id "b2") "click" nil) @@ -7486,11 +7500,12 @@ (dom-set-inner-html _el-span "A") (dom-add-class _el-span2 "b") (dom-set-inner-html _el-span2 "B") - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-button "_" "\\\"set") (dom-append (dom-body) _el-box) (dom-append _el-box _el-span) (dom-append _el-box _el-span2) (dom-append (dom-body) _el-button) + (hs-activate! _el-button) (dom-dispatch _el-button "click" nil) (assert= (dom-text-content _el-button) "1") )) @@ -7911,8 +7926,9 @@ (hs-cleanup!) (let ((_el-d1 (dom-create-element "div"))) (dom-set-attr _el-d1 "id" "d1") - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-d1 "_" "\\\"on") (dom-append (dom-body) _el-d1) + (hs-activate! _el-d1) (dom-dispatch (dom-query-by-id "d1") "click" nil) (assert= (dom-text-content (dom-query-by-id "d1")) "bar") )) @@ -7920,8 +7936,9 @@ (hs-cleanup!) (let ((_el-d1 (dom-create-element "div"))) (dom-set-attr _el-d1 "id" "d1") - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-d1 "_" "\\\"on") (dom-append (dom-body) _el-d1) + (hs-activate! _el-d1) (dom-dispatch (dom-query-by-id "d1") "click" nil) (assert= (dom-text-content (dom-query-by-id "d1")) "bar") )) @@ -7996,7 +8013,7 @@ (deftest "handles rejected promises without hanging" (hs-cleanup!) (let ((_el-div (dom-create-element "div"))) - (dom-set-attr _el-div "_" "on click js return Promise.reject(\"boom\") end catch e put e into my.innerHTML") + (dom-set-attr _el-div "_" "on click js return Promise.reject(\\\"boom\\\") end catch e put e into my.innerHTML") (dom-append (dom-body) _el-div) (hs-activate! _el-div) (dom-dispatch _el-div "click" nil) @@ -8078,7 +8095,7 @@ (deftest "the result in a when clause refers to previous command result, not element being tested" (hs-cleanup!) (let ((_el-div (dom-create-element "div")) (_el-s1 (dom-create-element "span")) (_el-s2 (dom-create-element "span"))) - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-div "_" "\\\"on") (dom-set-attr _el-s1 "id" "s1") (dom-set-attr _el-s1 "style" "display:none") (dom-set-inner-html _el-s1 "A") @@ -8088,6 +8105,7 @@ (dom-append (dom-body) _el-div) (dom-append (dom-body) _el-s1) (dom-append (dom-body) _el-s2) + (hs-activate! _el-div) (dom-dispatch _el-div "click" nil) (assert (dom-visible? (dom-query-by-id "s1"))) (assert (dom-visible? (dom-query-by-id "s2"))) @@ -8095,7 +8113,7 @@ (deftest "the result after show...when is the matched elements" (hs-cleanup!) (let ((_el-div (dom-create-element "div")) (_el-p (dom-create-element "p")) (_el-p2 (dom-create-element "p")) (_el-out (dom-create-element "span"))) - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-div "_" "\\\"on") (dom-set-attr _el-p "style" "display:none") (dom-set-inner-html _el-p "yes") (dom-set-attr _el-p2 "style" "display:none") @@ -8106,6 +8124,7 @@ (dom-append (dom-body) _el-p) (dom-append (dom-body) _el-p2) (dom-append (dom-body) _el-out) + (hs-activate! _el-div) (dom-dispatch _el-div "click" nil) (assert= (dom-text-content (dom-query-by-id "out")) "some") )) @@ -8234,7 +8253,7 @@ (deftest "can have comments in attributes (triple dash)" (hs-cleanup!) (let ((_el-div (dom-create-element "div"))) - (dom-set-attr _el-div "_" "on click put \"clicked\" into my.innerHTML ---put some content into the div...") + (dom-set-attr _el-div "_" "on click put \\\"clicked\\\" into my.innerHTML ---put some content into the div...") (dom-append (dom-body) _el-div) (hs-activate! _el-div) (dom-dispatch _el-div "click" nil) @@ -8244,7 +8263,7 @@ (hs-cleanup!) (let ((_el-d1 (dom-create-element "div"))) (dom-set-attr _el-d1 "id" "d1") - (dom-set-attr _el-d1 "_" "on click blargh end on mouseenter put \"hovered\" into my.innerHTML") + (dom-set-attr _el-d1 "_" "on click blargh end on mouseenter put \\\"hovered\\\" into my.innerHTML") (dom-append (dom-body) _el-d1) (hs-activate! _el-d1) )) @@ -8252,7 +8271,7 @@ (hs-cleanup!) (let ((_el-d1 (dom-create-element "div"))) (dom-set-attr _el-d1 "id" "d1") - (dom-set-attr _el-d1 "_" "on click blargh end on mouseenter also_bad end on focus put \"focused\" into my.innerHTML") + (dom-set-attr _el-d1 "_" "on click blargh end on mouseenter also_bad end on focus put \\\"focused\\\" into my.innerHTML") (dom-append (dom-body) _el-d1) (hs-activate! _el-d1) )) @@ -8264,7 +8283,7 @@ (dom-set-attr _el-d1 "id" "d1") (dom-set-attr _el-d1 "_" "on click blargh end on mouseenter also_bad") (dom-set-attr _el-d2 "id" "d2") - (dom-set-attr _el-d2 "_" "on click put \"clicked\" into my.innerHTML") + (dom-set-attr _el-d2 "_" "on click put \\\"clicked\\\" into my.innerHTML") (dom-append (dom-body) _el-div) (dom-append _el-div _el-d1) (dom-append _el-div _el-d2) @@ -8351,7 +8370,7 @@ (hs-cleanup!) (let ((_el-arDiv (dom-create-element "div"))) (dom-set-attr _el-arDiv "id" "arDiv") - (dom-set-attr _el-arDiv "_" "on click set my @data-foo to \"blue\"") + (dom-set-attr _el-arDiv "_" "on click set my @data-foo to \\\"blue\\\"") (dom-set-attr _el-arDiv "data-foo" "red") (dom-append (dom-body) _el-arDiv) (hs-activate! _el-arDiv) @@ -8367,7 +8386,7 @@ (dom-set-attr _el-outerDiv2 "id" "outerDiv2") (dom-set-attr _el-outerDiv2 "foo" "bar") (dom-set-attr _el-d1b "id" "d1b") - (dom-set-attr _el-d1b "_" "on click set closest @foo to \"doh\"") + (dom-set-attr _el-d1b "_" "on click set closest @foo to \\\"doh\\\"") (dom-append (dom-body) _el-outerDiv2) (dom-append _el-outerDiv2 _el-d1b) (hs-activate! _el-d1b) @@ -8381,7 +8400,7 @@ (dom-add-class _el-input4 "cb") (dom-set-attr _el-input4 "type" "checkbox") (dom-set-attr _el-master "id" "master") - ;; HS source has bare quotes or embedded HTML + (dom-set-attr _el-master "_" "\\\"set") (dom-set-attr _el-master "type" "checkbox") (dom-set-attr _el-master "-)), not nested. +# The directory named "tests" is also in the server's skip_dirs list, so we +# couldn't use /tests/ anyway. +PAGES_DIR = os.path.join(PROJECT_ROOT, 'sx/sx/applications/hyperscript') +GALLERY_SLUG = 'gallery' + + +def page_slug(parts): + """Build a dash-joined slug from path parts (theme, category, ...).""" + return '-'.join([GALLERY_SLUG] + [p for p in parts if p]) + + +def page_url(parts): + """Build the full /sx/... URL for a gallery slug.""" + return f'/sx/(applications.(hyperscript.{page_slug(parts)}))' + +# Six themes for grouping categories on the live gallery pages. +# Any category not listed here gets bucketed into 'misc'. +TEST_THEMES = { + 'dom': ['add', 'remove', 'toggle', 'set', 'put', 'append', 'hide', 'empty', + 'take', 'morph', 'show', 'measure', 'swap', 'focus', 'scroll', 'reset'], + 'events': ['on', 'when', 'send', 'tell', 'init', 'bootstrap', 'socket', + 'dialog', 'wait', 'halt', 'pick', 'fetch', 'asyncError'], + 'expressions': ['comparisonOperator', 'mathOperator', 'logicalOperator', + 'asExpression', 'collectionExpressions', 'closest', 'increment', + 'queryRef', 'attributeRef', 'objectLiteral', 'no', 'default', + 'in', 'splitJoin', 'select'], + 'control': ['if', 'repeat', 'go', 'call', 'log', 'settle'], + 'reactivity': ['bind', 'live', 'liveTemplate', 'reactive-properties', + 'transition', 'resize'], + 'language': ['def', 'component', 'parser', 'js', 'scoping', 'evalStatically', + 'askAnswer', 'assignableElements', + 'relativePositionalExpression', 'cookies', 'dom-scope'], +} + + +def theme_for_category(category): + for theme, cats in TEST_THEMES.items(): + if category in cats: + return theme + return 'misc' + + +def sx_str(s): + """Escape a Python string for inclusion as an SX string literal.""" + return '"' + s.replace('\\', '\\\\').replace('"', '\\"') + '"' + with open(INPUT) as f: raw_tests = json.load(f) @@ -530,9 +581,12 @@ def parse_dev_body(body, elements, var_names): def process_hs_val(hs_val): """Process a raw HS attribute value: collapse whitespace, insert 'then' separators.""" - # Convert escaped newlines/tabs to real whitespace before stripping backslashes + # Convert escaped newlines/tabs to real whitespace hs_val = hs_val.replace('\\n', '\n').replace('\\t', ' ') + # Preserve escaped quotes (\" → placeholder), strip remaining backslashes, restore + hs_val = hs_val.replace('\\"', '\x00QUOT\x00') hs_val = hs_val.replace('\\', '') + hs_val = hs_val.replace('\x00QUOT\x00', '\\"') cmd_kws = r'(?:set|put|get|add|remove|toggle|hide|show|if|repeat|for|wait|send|trigger|log|call|take|throw|return|append|tell|go|halt|settle|increment|decrement|fetch|make|install|measure|empty|reset|swap|default|morph|render|scroll|focus|select|pick|beep!)' hs_val = re.sub(r'\s{2,}(?=' + cmd_kws + r'\b)', ' then ', hs_val) hs_val = re.sub(r'\s*[\n\r]\s*', ' then ', hs_val) @@ -545,12 +599,16 @@ def process_hs_val(hs_val): return hs_val.strip() -def emit_element_setup(lines, elements, var_names): +def emit_element_setup(lines, elements, var_names, root='(dom-body)', indent=' '): """Emit SX for creating elements, setting attributes, appending to DOM, and activating. + root — where top-level elements get appended. Default (dom-body); for the gallery + card, callers pass a sandbox variable name so the HS runs inside the card, not on + the page body. + Three phases to ensure correct ordering: 1. Set attributes/content on all elements - 2. Append elements to their parents (children first, then parents to body) + 2. Append elements to their parents (children first, then roots to root) 3. Activate HS handlers (all elements in DOM) """ hs_elements = [] # indices of elements with valid HS @@ -559,41 +617,41 @@ def emit_element_setup(lines, elements, var_names): for i, el in enumerate(elements): var = var_names[i] if el['id']: - lines.append(f' (dom-set-attr {var} "id" "{el["id"]}")') + lines.append(f'{indent}(dom-set-attr {var} "id" "{el["id"]}")') for cls in el['classes']: - lines.append(f' (dom-add-class {var} "{cls}")') + lines.append(f'{indent}(dom-add-class {var} "{cls}")') if el['hs']: hs_val = process_hs_val(el['hs']) if not hs_val: pass # no HS to set elif hs_val.startswith('"') or (hs_val.endswith('"') and '<' in hs_val): - lines.append(f' ;; HS source has bare quotes or embedded HTML') + lines.append(f'{indent};; HS source has bare quotes or embedded HTML') else: hs_escaped = hs_val.replace('\\', '\\\\').replace('"', '\\"') - lines.append(f' (dom-set-attr {var} "_" "{hs_escaped}")') + lines.append(f'{indent}(dom-set-attr {var} "_" "{hs_escaped}")') hs_elements.append(i) for aname, aval in el['attrs'].items(): if '\\' in aval or '\n' in aval or aname.startswith('['): - lines.append(f' ;; SKIP attr {aname} (contains special chars)') + lines.append(f'{indent};; SKIP attr {aname} (contains special chars)') continue aval_escaped = aval.replace('"', '\\"') - lines.append(f' (dom-set-attr {var} "{aname}" "{aval_escaped}")') + lines.append(f'{indent}(dom-set-attr {var} "{aname}" "{aval_escaped}")') if el['inner']: inner_escaped = el['inner'].replace('\\', '\\\\').replace('"', '\\"') - lines.append(f' (dom-set-inner-html {var} "{inner_escaped}")') + lines.append(f'{indent}(dom-set-inner-html {var} "{inner_escaped}")') - # Phase 2: Append elements (children to parents, roots to body) + # Phase 2: Append elements (children to parents, roots to `root`) for i, el in enumerate(elements): var = var_names[i] if el['parent_idx'] is not None: parent_var = var_names[el['parent_idx']] - lines.append(f' (dom-append {parent_var} {var})') + lines.append(f'{indent}(dom-append {parent_var} {var})') else: - lines.append(f' (dom-append (dom-body) {var})') + lines.append(f'{indent}(dom-append {root} {var})') # Phase 3: Activate HS handlers (all elements now in DOM) for i in hs_elements: - lines.append(f' (hs-activate! {var_names[i]})') + lines.append(f'{indent}(hs-activate! {var_names[i]})') def generate_test_chai(test, elements, var_names, idx): @@ -905,6 +963,215 @@ def generate_test(test, idx): return generate_test_chai(test, elements, var_names, idx) +# ── Live gallery pages ──────────────────────────────────────────── + +PAGE_HEADER = ( + ';; AUTO-GENERATED from spec/tests/hyperscript-upstream-tests.json\n' + ';; DO NOT EDIT — regenerate with:\n' + ';; python3 tests/playwright/generate-sx-tests.py --emit-pages\n' +) + +# Actions/checks that we can't yet compile into a runner body emit a placeholder +# runner that throws; the card still renders so users can see the source. This +# keeps gallery coverage 1:1 with the JSON source of truth. +NOT_DEMONSTRABLE = '(error "not yet runnable in gallery — see test suite")' + + +def emit_runner_body(test, elements, var_names): + """Emit the body of the runner lambda that runs inside a sandbox element. + + Returns an SX expression string or None if the test can't be reproduced + (no HTML, unparseable action, etc.).""" + if not elements: + return None + + ref = make_ref_fn(elements, var_names) + actions = parse_action(test.get('action', ''), ref) + checks_parsed = parse_checks(test.get('check', '')) + + # Skip-only action list (no real action) → nothing to demonstrate + real_actions = [a for a in actions if not a.startswith(';;')] + if not real_actions: + return None + + lines = [] + bindings = ' '.join( + f'({var_names[i]} (dom-create-element "{el["tag"]}"))' + for i, el in enumerate(elements) + ) + lines.append(f'(fn (sandbox)') + lines.append(f' (let ({bindings})') + emit_element_setup(lines, elements, var_names, root='sandbox', indent=' ') + for a in actions: + lines.append(f' {a}') + for c in checks_parsed: + sx = check_to_sx(c, ref) + lines.append(f' {sx}') + lines.append(' ))') + return '\n'.join(lines) + + +def emit_card(test): + """Return an SX (~hyperscript/hs-test-card ...) call for one test.""" + name_sx = sx_str(test['name']) + html_sx = sx_str(test.get('html', '') or '') + action_sx = sx_str(test.get('action', '') or '') + check_sx = sx_str(test.get('check', '') or '') + + elements = parse_html(test.get('html', '')) + var_names = assign_var_names(elements) if elements else [] + runner = emit_runner_body(test, elements, var_names) + if runner is None: + runner = f'(fn (sandbox) {NOT_DEMONSTRABLE})' + + # :run-src is SX SOURCE TEXT — a string the island parses + evals at Run + # time. Ordinary lambda kwargs (and even bare quoted `(fn ...)` lists) + # end up lambda-ified by the prop pipeline and print as "" + # through aser, which can't round-trip. Strings do. + run_src = sx_str(runner) + return ( + f'(~hyperscript/hs-test-card\n' + f' :name {name_sx}\n' + f' :html {html_sx}\n' + f' :action {action_sx}\n' + f' :check {check_sx}\n' + f' :run-src {run_src})' + ) + + +def emit_category_page(theme, category, tests): + """Return SX source for one category page (all tests in that category).""" + total = len(tests) + runnable = sum( + 1 for t in tests + if parse_html(t.get('html', '')) and + any(not a.startswith(';;') for a in + parse_action(t.get('action', ''), + make_ref_fn(parse_html(t.get('html', '')), + assign_var_names(parse_html(t.get('html', '')))))) + ) + cards = '\n'.join(emit_card(t) for t in tests) + title = f'Hyperscript: {category} ({total} tests — {runnable} runnable)' + intro = ( + f'Live cards for the upstream {category} tests. ' + f'{runnable} of {total} are reproducible in-browser; ' + f'the remainder show their source for reference.' + ) + return ( + PAGE_HEADER + '\n' + f'(defcomp ()\n' + f' (~docs/page :title {sx_str(title)}\n' + f' (p :style "color:#57534e;margin-bottom:1rem" {sx_str(intro)})\n' + f' (p :style "color:#78716c;font-size:0.875rem;margin-bottom:1rem"\n' + f' "Theme: " (a :href {sx_str(page_url([theme]))}\n' + f' :style "color:#7c3aed" {sx_str(theme)}))\n' + f' (div :style "display:flex;flex-direction:column"\n' + f' {cards})))\n' + ) + + +def emit_theme_index(theme, cats_in_theme, cats_to_tests): + """Return SX source for a theme index page (list of its categories).""" + total = sum(len(cats_to_tests.get(c, [])) for c in cats_in_theme) + links = [] + for cat in cats_in_theme: + if cat not in cats_to_tests: + continue + n = len(cats_to_tests[cat]) + href = page_url([theme, cat]) + links.append( + f' (li :style "margin-bottom:0.25rem"\n' + f' (a :href {sx_str(href)} :style "color:#7c3aed;text-decoration:underline"\n' + f' {sx_str(cat)})\n' + f' (span :style "color:#78716c;margin-left:0.5rem;font-size:0.875rem"\n' + f' {sx_str(f"({n} tests)")}))' + ) + title = f'Hyperscript tests: {theme} ({total} tests)' + return ( + PAGE_HEADER + '\n' + f'(defcomp ()\n' + f' (~docs/page :title {sx_str(title)}\n' + f' (p :style "color:#57534e;margin-bottom:1rem"\n' + f' "Pick a category to see its live test cards.")\n' + f' (ul :style "list-style:disc;padding-left:1.5rem"\n' + + '\n'.join(links) + '\n' + f' )))\n' + ) + + +def emit_top_index(themes_with_counts): + """Return SX source for the top-level /tests index page.""" + links = [] + for theme, count in themes_with_counts: + href = page_url([theme]) + links.append( + f' (li :style "margin-bottom:0.25rem"\n' + f' (a :href {sx_str(href)} :style "color:#7c3aed;text-decoration:underline;font-weight:500"\n' + f' {sx_str(theme)})\n' + f' (span :style "color:#78716c;margin-left:0.5rem;font-size:0.875rem"\n' + f' {sx_str(f"({count} tests)")}))' + ) + grand_total = sum(c for _, c in themes_with_counts) + title = f'Hyperscript test gallery ({grand_total} tests)' + return ( + PAGE_HEADER + '\n' + f'(defcomp ()\n' + f' (~docs/page :title {sx_str(title)}\n' + f' (p :style "color:#57534e;margin-bottom:1rem"\n' + f' "Live cards for every upstream _hyperscript behavioural test. "\n' + f' "Each card renders the HTML into a sandbox, activates the hyperscript, "\n' + f' "dispatches the action, and runs the assertion. Pass/fail is shown "\n' + f' "with the same runtime path as the SX test suite.")\n' + f' (ul :style "list-style:disc;padding-left:1.5rem"\n' + + '\n'.join(links) + '\n' + f' )))\n' + ) + + +def write_page_files(categories): + """Write gallery files. Everything is flat in applications/hyperscript/ — + gallery.sx (top), gallery-.sx, gallery--.sx — + because the /sx/ router only dispatches one level per page-fn call.""" + # Bucket categories by theme + themed = OrderedDict() # theme -> [(cat, tests)] + for cat, tests in categories.items(): + theme = theme_for_category(cat) + themed.setdefault(theme, []).append((cat, tests)) + + # Remove any previous gallery-*.sx files so stale themes don't linger + if os.path.isdir(PAGES_DIR): + for fname in os.listdir(PAGES_DIR): + if fname == f'{GALLERY_SLUG}.sx' or fname.startswith(f'{GALLERY_SLUG}-'): + try: os.remove(os.path.join(PAGES_DIR, fname)) + except OSError: pass + + themes_with_counts = [] + written = [] + for theme, cat_pairs in themed.items(): + cats_in_theme = [c for c, _ in cat_pairs] + cats_to_tests = {c: ts for c, ts in cat_pairs} + + for cat, tests in cat_pairs: + fname = f'{page_slug([theme, cat])}.sx' + with open(os.path.join(PAGES_DIR, fname), 'w') as f: + f.write(emit_category_page(theme, cat, tests)) + written.append(fname) + + fname = f'{page_slug([theme])}.sx' + with open(os.path.join(PAGES_DIR, fname), 'w') as f: + f.write(emit_theme_index(theme, cats_in_theme, cats_to_tests)) + written.append(fname) + + themes_with_counts.append((theme, sum(len(ts) for _, ts in cat_pairs))) + + fname = f'{GALLERY_SLUG}.sx' + with open(os.path.join(PAGES_DIR, fname), 'w') as f: + f.write(emit_top_index(themes_with_counts)) + written.append(fname) + + return themed, written + + # ── Output generation ───────────────────────────────────────────── output = [] @@ -989,3 +1256,15 @@ print(f' Categories: {len(categories)}') for cat, (gen, stub) in generated_counts.items(): marker = '' if stub == 0 else f' ({stub} stubs)' print(f' {cat}: {gen}{marker}') + + +# ── Optional: live gallery pages ────────────────────────────────── + +import sys +if '--emit-pages' in sys.argv: + themed, written = write_page_files(categories) + print(f'\nGallery pages written under {PAGES_DIR} ({len(written)} files)') + for theme, pairs in themed.items(): + cats = ', '.join(c for c, _ in pairs) + total_t = sum(len(ts) for _, ts in pairs) + print(f' {theme} ({total_t} tests, {len(pairs)} categories): {cats}')