Tests: cek-try-seq / htmx / hs-diag / perform-chain + node HS runners
New spec tests: test-cek-try-seq (CEK try/seq), test-htmx (htmx directive coverage, 292L), test-hs-diag, test-perform-chain (IO suspension chains). tests/hs-*.js: Node.js-side hyperscript runners for browser-mode testing (hs-behavioral-node, hs-behavioral-runner, hs-parse-audit, hs-run-timed). Vendors shared/static/scripts/htmx.min.js. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
shared/static/scripts/htmx.min.js
vendored
Normal file
1
shared/static/scripts/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
64
spec/tests/test-cek-try-seq.sx
Normal file
64
spec/tests/test-cek-try-seq.sx
Normal file
@@ -0,0 +1,64 @@
|
||||
;; Tests for cek-try sequential execution
|
||||
;; Bug: hydrate-island fallback leaves hydrating? scope active
|
||||
|
||||
(defsuite
|
||||
"cek-try-sequential"
|
||||
(deftest
|
||||
"code after cek-try runs on success"
|
||||
(let
|
||||
((result (list)))
|
||||
(append! result "before")
|
||||
(cek-try (fn () (append! result "body") 42) (fn (e) nil))
|
||||
(append! result "after")
|
||||
(assert= 3 (len result))
|
||||
(assert= "after" (nth result 2))))
|
||||
(deftest
|
||||
"code after cek-try runs on error"
|
||||
(let
|
||||
((result (list)))
|
||||
(append! result "before")
|
||||
(cek-try (fn () (error "boom")) (fn (e) (append! result "caught")))
|
||||
(append! result "after")
|
||||
(assert= 3 (len result))
|
||||
(assert= "after" (nth result 2))))
|
||||
(deftest
|
||||
"error in error handler propagates — skips post-try code"
|
||||
(let
|
||||
((result (list)))
|
||||
(append! result "before")
|
||||
(guard
|
||||
(outer-err (true (append! result "outer-caught")))
|
||||
(cek-try (fn () (error "boom")) (fn (e) (error "handler-boom")))
|
||||
(append! result "after-try"))
|
||||
(assert-true (contains? result "before"))
|
||||
(assert-true (contains? result "outer-caught"))
|
||||
(assert= false (contains? result "after-try"))))
|
||||
(deftest
|
||||
"scope-pop after cek-try executes on error"
|
||||
(scope-push! "test-scope" "value")
|
||||
(cek-try (fn () (error "boom")) (fn (e) nil))
|
||||
(scope-pop! "test-scope")
|
||||
(assert= nil (scope-peek "test-scope")))
|
||||
(deftest
|
||||
"scope-push/pop balanced across cek-try error"
|
||||
(let
|
||||
((result nil))
|
||||
(scope-push! "bal-test" "pushed")
|
||||
(cek-try (fn () (error "fail")) (fn (e) nil))
|
||||
(set! result (scope-peek "bal-test"))
|
||||
(scope-pop! "bal-test")
|
||||
(assert= "pushed" result)
|
||||
(assert= nil (scope-peek "bal-test"))))
|
||||
(deftest
|
||||
"error handler that errors skips cleanup"
|
||||
(let
|
||||
((cleaned false))
|
||||
(scope-push! "cleanup-test" "val")
|
||||
(guard
|
||||
(e (true nil))
|
||||
(cek-try (fn () (error "first")) (fn (e) (error "second")))
|
||||
(scope-pop! "cleanup-test")
|
||||
(set! cleaned true))
|
||||
(assert= false cleaned)
|
||||
(assert= "val" (scope-peek "cleanup-test"))
|
||||
(scope-pop! "cleanup-test"))))
|
||||
17
spec/tests/test-hs-diag.sx
Normal file
17
spec/tests/test-hs-diag.sx
Normal file
@@ -0,0 +1,17 @@
|
||||
(defsuite
|
||||
"hs-diag"
|
||||
(deftest
|
||||
"put into #id compiled"
|
||||
(let
|
||||
((sx (hs-to-sx-from-source "on click put \"foo\" into #d1")))
|
||||
(assert= (serialize sx) "SHOW")))
|
||||
(deftest
|
||||
"put into #id works"
|
||||
(let
|
||||
((el (dom-create-element "div")))
|
||||
(dom-set-attr el "id" "d1")
|
||||
(dom-set-attr el "_" "on click put \"foo\" into #d1")
|
||||
(dom-append (dom-body) el)
|
||||
(hs-activate! el)
|
||||
(dom-dispatch el "click" nil)
|
||||
(assert= (dom-text-content el) "foo"))))
|
||||
292
spec/tests/test-htmx.sx
Normal file
292
spec/tests/test-htmx.sx
Normal file
@@ -0,0 +1,292 @@
|
||||
;; test-htmx.sx — Tests for htmx 4.0 compatibility layer
|
||||
;;
|
||||
;; Tests the attribute-to-handler translator: pure parsing functions,
|
||||
;; swap mode resolution, trigger parsing, and DOM integration via harness.
|
||||
|
||||
;; ── Time parsing ────────────────────────────────────────────────
|
||||
|
||||
(defsuite
|
||||
"htmx-parse-time"
|
||||
(deftest "parses milliseconds" (assert= (hx-parse-time "500ms") 500))
|
||||
(deftest "parses seconds" (assert= (hx-parse-time "1s") 1000))
|
||||
(deftest "parses fractional seconds" (assert= (hx-parse-time "0.5s") 500))
|
||||
(deftest "parses minutes" (assert= (hx-parse-time "2m") 120000))
|
||||
(deftest "parses bare number" (assert= (hx-parse-time "100") 100))
|
||||
(deftest "returns nil for nil" (assert= (hx-parse-time nil) nil)))
|
||||
|
||||
;; ── Swap mode normalization (v4 aliases) ────────────────────────
|
||||
|
||||
(defsuite
|
||||
"htmx-swap-aliases"
|
||||
(deftest
|
||||
"before → beforebegin"
|
||||
(assert= (hx-normalize-swap-mode "before") "beforebegin"))
|
||||
(deftest
|
||||
"after → afterend"
|
||||
(assert= (hx-normalize-swap-mode "after") "afterend"))
|
||||
(deftest
|
||||
"prepend → afterbegin"
|
||||
(assert= (hx-normalize-swap-mode "prepend") "afterbegin"))
|
||||
(deftest
|
||||
"append → beforeend"
|
||||
(assert= (hx-normalize-swap-mode "append") "beforeend"))
|
||||
(deftest
|
||||
"innerHTML passes through"
|
||||
(assert= (hx-normalize-swap-mode "innerHTML") "innerHTML"))
|
||||
(deftest
|
||||
"outerHTML passes through"
|
||||
(assert= (hx-normalize-swap-mode "outerHTML") "outerHTML"))
|
||||
(deftest
|
||||
"delete passes through"
|
||||
(assert= (hx-normalize-swap-mode "delete") "delete"))
|
||||
(deftest
|
||||
"innerMorph passes through"
|
||||
(assert= (hx-normalize-swap-mode "innerMorph") "innerMorph"))
|
||||
(deftest
|
||||
"outerMorph passes through"
|
||||
(assert= (hx-normalize-swap-mode "outerMorph") "outerMorph"))
|
||||
(deftest
|
||||
"textContent passes through"
|
||||
(assert= (hx-normalize-swap-mode "textContent") "textContent")))
|
||||
|
||||
;; ── Swap spec parsing ───────────────────────────────────────────
|
||||
|
||||
(defsuite
|
||||
"htmx-parse-swap-spec"
|
||||
(deftest
|
||||
"nil defaults to innerHTML"
|
||||
(let
|
||||
((spec (hx-parse-swap-spec nil)))
|
||||
(assert= (get spec :mode) "innerHTML")))
|
||||
(deftest
|
||||
"bare mode"
|
||||
(let
|
||||
((spec (hx-parse-swap-spec "outerHTML")))
|
||||
(assert= (get spec :mode) "outerHTML")
|
||||
(assert= (get spec :swap-delay) nil)))
|
||||
(deftest
|
||||
"mode with swap delay"
|
||||
(let
|
||||
((spec (hx-parse-swap-spec "innerHTML swap:100ms")))
|
||||
(assert= (get spec :mode) "innerHTML")
|
||||
(assert= (get spec :swap-delay) 100)))
|
||||
(deftest
|
||||
"mode with settle delay"
|
||||
(let
|
||||
((spec (hx-parse-swap-spec "innerHTML settle:200ms")))
|
||||
(assert= (get spec :settle-delay) 200)))
|
||||
(deftest
|
||||
"mode with scroll"
|
||||
(let
|
||||
((spec (hx-parse-swap-spec "innerHTML scroll:top")))
|
||||
(assert= (get spec :scroll) "top")))
|
||||
(deftest
|
||||
"v4 alias normalized in spec"
|
||||
(let
|
||||
((spec (hx-parse-swap-spec "append settle:500ms")))
|
||||
(assert= (get spec :mode) "beforeend")
|
||||
(assert= (get spec :settle-delay) 500)))
|
||||
(deftest
|
||||
"full spec with multiple modifiers"
|
||||
(let
|
||||
((spec (hx-parse-swap-spec "innerHTML swap:50ms settle:100ms scroll:top")))
|
||||
(assert= (get spec :mode) "innerHTML")
|
||||
(assert= (get spec :swap-delay) 50)
|
||||
(assert= (get spec :settle-delay) 100)
|
||||
(assert= (get spec :scroll) "top"))))
|
||||
|
||||
;; ── Trigger parsing ─────────────────────────────────────────────
|
||||
|
||||
(defsuite
|
||||
"htmx-parse-trigger"
|
||||
(deftest
|
||||
"simple event"
|
||||
(let
|
||||
((spec (hx-parse-trigger "click" nil)))
|
||||
(assert= (get spec :event) "click")
|
||||
(assert= (get spec :delay) nil)
|
||||
(assert= (get spec :once) false)))
|
||||
(deftest
|
||||
"event with delay"
|
||||
(let
|
||||
((spec (hx-parse-trigger "keyup delay:500ms" nil)))
|
||||
(assert= (get spec :event) "keyup")
|
||||
(assert= (get spec :delay) 500)))
|
||||
(deftest
|
||||
"event with throttle"
|
||||
(let
|
||||
((spec (hx-parse-trigger "click throttle:1s" nil)))
|
||||
(assert= (get spec :event) "click")
|
||||
(assert= (get spec :throttle) 1000)))
|
||||
(deftest
|
||||
"event with once"
|
||||
(let
|
||||
((spec (hx-parse-trigger "click once" nil)))
|
||||
(assert= (get spec :event) "click")
|
||||
(assert= (get spec :once) true)))
|
||||
(deftest
|
||||
"event with changed"
|
||||
(let
|
||||
((spec (hx-parse-trigger "keyup changed delay:500ms" nil)))
|
||||
(assert= (get spec :event) "keyup")
|
||||
(assert= (get spec :changed) true)
|
||||
(assert= (get spec :delay) 500)))
|
||||
(deftest
|
||||
"event with from selector"
|
||||
(let
|
||||
((spec (hx-parse-trigger "click from:body" nil)))
|
||||
(assert= (get spec :event) "click")
|
||||
(assert= (get spec :from) "body")))
|
||||
(deftest
|
||||
"event with filter"
|
||||
(let
|
||||
((spec (hx-parse-trigger "keyup [key=='Enter']" nil)))
|
||||
(assert= (get spec :event) "keyup")
|
||||
(assert= (get spec :filter) "[key=='Enter']")))
|
||||
(deftest
|
||||
"every trigger"
|
||||
(let
|
||||
((spec (hx-parse-trigger "every delay:2s" nil)))
|
||||
(assert= (get spec :event) "every")
|
||||
(assert= (get spec :delay) 2000))))
|
||||
|
||||
;; ── URL encoding ────────────────────────────────────────────────
|
||||
|
||||
(defsuite
|
||||
"htmx-url-encode"
|
||||
(deftest
|
||||
"encodes single param"
|
||||
(assert= (url-encode-params {:q "search"}) "q=search"))
|
||||
(deftest
|
||||
"encodes numeric values"
|
||||
(assert= (url-encode-params {:page 1}) "page=1")))
|
||||
|
||||
(defsuite
|
||||
"htmx-status-matches"
|
||||
(deftest "exact match" (assert= (hx-status-matches? "404" "404") true))
|
||||
(deftest
|
||||
"exact non-match"
|
||||
(assert= (hx-status-matches? "404" "500") false))
|
||||
(deftest
|
||||
"1-digit wildcard 5xx matches 503"
|
||||
(assert= (hx-status-matches? "503" "5xx") true))
|
||||
(deftest
|
||||
"1-digit wildcard 4xx does not match 503"
|
||||
(assert= (hx-status-matches? "503" "4xx") false))
|
||||
(deftest
|
||||
"2-digit wildcard 50x matches 503"
|
||||
(assert= (hx-status-matches? "503" "50x") true))
|
||||
(deftest
|
||||
"2-digit wildcard 50x does not match 522"
|
||||
(assert= (hx-status-matches? "522" "50x") false))
|
||||
(deftest "2xx matches 200" (assert= (hx-status-matches? "200" "2xx") true)))
|
||||
|
||||
(defsuite
|
||||
"htmx-status-modifiers"
|
||||
(deftest
|
||||
"parses swap target push"
|
||||
(let
|
||||
((m (hx-parse-status-modifiers "swap:innerHTML target:#errors push:false")))
|
||||
(assert= (get m :swap) "innerHTML")
|
||||
(assert= (get m :target) "#errors")
|
||||
(assert= (get m :push) "false")))
|
||||
(deftest
|
||||
"parses transition"
|
||||
(let
|
||||
((m (hx-parse-status-modifiers "swap:none transition:true")))
|
||||
(assert= (get m :swap) "none")
|
||||
(assert= (get m :transition) "true"))))
|
||||
|
||||
(defsuite
|
||||
"htmx-match-status"
|
||||
(deftest
|
||||
"exact match wins over wildcard"
|
||||
(let
|
||||
((rules (list {:target nil :transition nil :swap "none" :select nil :push nil :specificity 1 :code "5xx" :replace nil} {:target nil :transition nil :swap "outerHTML" :select nil :push nil :specificity 3 :code "503" :replace nil})))
|
||||
(assert= (get (hx-match-status 503 rules) :swap) "outerHTML")))
|
||||
(deftest
|
||||
"2-digit wildcard wins over 1-digit"
|
||||
(let
|
||||
((rules (list {:target nil :transition nil :swap "none" :select nil :push nil :specificity 1 :code "5xx" :replace nil} {:target nil :transition nil :swap "innerHTML" :select nil :push nil :specificity 2 :code "50x" :replace nil})))
|
||||
(assert= (get (hx-match-status 501 rules) :swap) "innerHTML")))
|
||||
(deftest
|
||||
"nil when no match"
|
||||
(let
|
||||
((rules (list {:target nil :transition nil :swap "none" :select nil :push nil :specificity 1 :code "5xx" :replace nil})))
|
||||
(assert= (hx-match-status 404 rules) nil))))
|
||||
|
||||
(defsuite
|
||||
"htmx-sync-spec"
|
||||
(deftest
|
||||
"parses selector:strategy"
|
||||
(let
|
||||
((s (hx-parse-sync-spec "closest form:abort")))
|
||||
(assert= (get s :selector) "closest form")
|
||||
(assert= (get s :strategy) "abort")))
|
||||
(deftest
|
||||
"parses queue with mode"
|
||||
(let
|
||||
((s (hx-parse-sync-spec "this:queue last")))
|
||||
(assert= (get s :selector) "this")
|
||||
(assert= (get s :strategy) "queue")
|
||||
(assert= (get s :queue-mode) "last")))
|
||||
(deftest
|
||||
"defaults to drop strategy"
|
||||
(let
|
||||
((s (hx-parse-sync-spec "this:drop")))
|
||||
(assert= (get s :strategy) "drop")))
|
||||
(deftest "nil for nil input" (assert= (hx-parse-sync-spec nil) nil)))
|
||||
|
||||
(defsuite
|
||||
"htmx-sse-swap-parse"
|
||||
(deftest
|
||||
"parses single spec"
|
||||
(let
|
||||
((specs (hx-parse-sse-swap "message:#target")))
|
||||
(assert= (len specs) 1)
|
||||
(assert= (get (first specs) :event) "message")
|
||||
(assert= (get (first specs) :target) "#target")))
|
||||
(deftest
|
||||
"parses multiple specs with swap mode"
|
||||
(let
|
||||
((specs (hx-parse-sse-swap "message:#target,update:#list:outerHTML")))
|
||||
(assert= (len specs) 2)
|
||||
(assert= (get (nth specs 1) :event) "update")
|
||||
(assert= (get (nth specs 1) :swap) "outerHTML")))
|
||||
(deftest
|
||||
"nil returns empty list"
|
||||
(assert= (hx-parse-sse-swap nil) (list))))
|
||||
|
||||
(defsuite
|
||||
"htmx-swap-spec-v4-modifiers"
|
||||
(deftest
|
||||
"transition modifier"
|
||||
(let
|
||||
((spec (hx-parse-swap-spec "innerHTML transition:true")))
|
||||
(assert= (get spec :transition) true)
|
||||
(assert= (get spec :mode) "innerHTML")))
|
||||
(deftest
|
||||
"strip modifier"
|
||||
(let
|
||||
((spec (hx-parse-swap-spec "outerHTML strip:true")))
|
||||
(assert= (get spec :strip) true)))
|
||||
(deftest
|
||||
"target override in swap spec"
|
||||
(let
|
||||
((spec (hx-parse-swap-spec "innerHTML target:#alt")))
|
||||
(assert= (get spec :target) "#alt")))
|
||||
(deftest
|
||||
"ignoreTitle modifier"
|
||||
(let
|
||||
((spec (hx-parse-swap-spec "innerHTML ignoreTitle:true")))
|
||||
(assert= (get spec :ignore-title) true)))
|
||||
(deftest
|
||||
"all modifiers together"
|
||||
(let
|
||||
((spec (hx-parse-swap-spec "append swap:50ms settle:100ms scroll:top transition:true strip:true")))
|
||||
(assert= (get spec :mode) "beforeend")
|
||||
(assert= (get spec :swap-delay) 50)
|
||||
(assert= (get spec :settle-delay) 100)
|
||||
(assert= (get spec :scroll) "top")
|
||||
(assert= (get spec :transition) true)
|
||||
(assert= (get spec :strip) true))))
|
||||
63
spec/tests/test-perform-chain.sx
Normal file
63
spec/tests/test-perform-chain.sx
Normal file
@@ -0,0 +1,63 @@
|
||||
;; Tests for perform/IO suspension chaining through for-each
|
||||
;; Bug: after resume from first perform inside for-each,
|
||||
;; subsequent iterations' performs don't suspend — they complete
|
||||
;; synchronously, breaking multi-step async sequences like test runners.
|
||||
|
||||
(defsuite
|
||||
"perform-for-each-chain"
|
||||
(deftest
|
||||
"for-each with perform suspends on each iteration"
|
||||
(let
|
||||
((state1 (cek-step-loop (make-cek-state (quote (let ((results (list))) (for-each (fn (item) (let ((val (perform {:op "fetch" :key item}))) (append! results val))) (list "a" "b" "c")) results)) (make-env) (list)))))
|
||||
(assert-true (cek-suspended? state1))
|
||||
(assert= "a" (get (cek-io-request state1) :key))
|
||||
(let
|
||||
((state2 (cek-resume state1 "result-a")))
|
||||
(assert-true (cek-suspended? state2))
|
||||
(assert= "b" (get (cek-io-request state2) :key))
|
||||
(let
|
||||
((state3 (cek-resume state2 "result-b")))
|
||||
(assert-true (cek-suspended? state3))
|
||||
(assert= "c" (get (cek-io-request state3) :key))
|
||||
(let
|
||||
((final (cek-resume state3 "result-c")))
|
||||
(assert-true (cek-terminal? final))
|
||||
(assert= 3 (len (cek-value final))))))))
|
||||
(deftest
|
||||
"for-each with guard and perform chains correctly"
|
||||
(let
|
||||
((state1 (cek-step-loop (make-cek-state (quote (let ((results (list))) (for-each (fn (item) (guard (e (true (append! results (str "fail:" item)))) (let ((val (perform {:op "fetch" :key item}))) (append! results (str "ok:" val))))) (list "x" "y")) results)) (make-env) (list)))))
|
||||
(assert-true (cek-suspended? state1))
|
||||
(let
|
||||
((state2 (cek-resume state1 "X")))
|
||||
(assert-true (cek-suspended? state2))
|
||||
(let
|
||||
((final (cek-resume state2 "Y")))
|
||||
(assert-true (cek-terminal? final))
|
||||
(let
|
||||
((results (cek-value final)))
|
||||
(assert= 2 (len results))
|
||||
(assert= "ok:X" (nth results 0))
|
||||
(assert= "ok:Y" (nth results 1)))))))
|
||||
(deftest
|
||||
"nested performs in for-each — reload + wait pattern"
|
||||
(let
|
||||
((state1 (cek-step-loop (make-cek-state (quote (let ((log (list))) (for-each (fn (name) (perform {:op "wait" :ms 1000}) (append! log (str "reloaded:" name)) (perform {:op "wait" :ms 500}) (append! log (str "done:" name))) (list "t1" "t2")) log)) (make-env) (list)))))
|
||||
(assert-true (cek-suspended? state1))
|
||||
(assert= 1000 (get (cek-io-request state1) :ms))
|
||||
(let
|
||||
((s2 (cek-resume state1 nil)))
|
||||
(assert-true (cek-suspended? s2))
|
||||
(assert= 500 (get (cek-io-request s2) :ms))
|
||||
(let
|
||||
((s3 (cek-resume s2 nil)))
|
||||
(assert-true (cek-suspended? s3))
|
||||
(assert= 1000 (get (cek-io-request s3) :ms))
|
||||
(let
|
||||
((s4 (cek-resume s3 nil)))
|
||||
(assert-true (cek-suspended? s4))
|
||||
(assert= 500 (get (cek-io-request s4) :ms))
|
||||
(let
|
||||
((final (cek-resume s4 nil)))
|
||||
(assert-true (cek-terminal? final))
|
||||
(assert= 4 (len (cek-value final))))))))))
|
||||
448
tests/hs-behavioral-node.js
Normal file
448
tests/hs-behavioral-node.js
Normal file
@@ -0,0 +1,448 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run hyperscript behavioral tests in Node.js using the WASM kernel.
|
||||
* No browser needed — uses a minimal DOM mock.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PROJECT = path.resolve(__dirname, '..');
|
||||
const WASM_DIR = path.join(PROJECT, 'shared/static/wasm');
|
||||
const SX_DIR = path.join(WASM_DIR, 'sx');
|
||||
|
||||
// Load WASM kernel
|
||||
eval(fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8'));
|
||||
const K = globalThis.SxKernel;
|
||||
|
||||
// ─── Minimal DOM mock ───────────────────────────────────────────
|
||||
class MockElement {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.nodeName = this.tagName;
|
||||
this.nodeType = 1;
|
||||
this.id = '';
|
||||
this.className = '';
|
||||
this.classList = new MockClassList(this);
|
||||
this.style = {};
|
||||
this.attributes = {};
|
||||
this.children = [];
|
||||
this.childNodes = [];
|
||||
this.parentElement = null;
|
||||
this.parentNode = null;
|
||||
this.textContent = '';
|
||||
this.innerHTML = '';
|
||||
this.outerHTML = '';
|
||||
this._listeners = {};
|
||||
this._hs = {}; // hyperscript data
|
||||
this.dataset = {};
|
||||
this.open = false; // for dialog
|
||||
}
|
||||
setAttribute(name, val) { this.attributes[name] = val; if (name === 'id') this.id = val; if (name === 'class') { this.className = val; this.classList._sync(val); } }
|
||||
getAttribute(name) { return this.attributes[name] !== undefined ? this.attributes[name] : null; }
|
||||
removeAttribute(name) { delete this.attributes[name]; }
|
||||
hasAttribute(name) { return name in this.attributes; }
|
||||
addEventListener(evt, fn, opts) {
|
||||
if (!this._listeners[evt]) this._listeners[evt] = [];
|
||||
this._listeners[evt].push(fn);
|
||||
}
|
||||
removeEventListener(evt, fn) {
|
||||
if (this._listeners[evt]) this._listeners[evt] = this._listeners[evt].filter(f => f !== fn);
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
event.target = this;
|
||||
event.currentTarget = this;
|
||||
const fns = this._listeners[event.type] || [];
|
||||
for (const fn of fns) {
|
||||
try { fn(event); } catch(e) {}
|
||||
}
|
||||
// Bubble
|
||||
if (event.bubbles && !event._stopped && this.parentElement) {
|
||||
event.currentTarget = this.parentElement;
|
||||
this.parentElement.dispatchEvent(event);
|
||||
}
|
||||
return !event.defaultPrevented;
|
||||
}
|
||||
appendChild(child) {
|
||||
if (child.parentElement) child.parentElement.removeChild(child);
|
||||
child.parentElement = this;
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
this.childNodes.push(child);
|
||||
return child;
|
||||
}
|
||||
removeChild(child) {
|
||||
this.children = this.children.filter(c => c !== child);
|
||||
this.childNodes = this.childNodes.filter(c => c !== child);
|
||||
child.parentElement = null;
|
||||
child.parentNode = null;
|
||||
return child;
|
||||
}
|
||||
insertBefore(newChild, refChild) {
|
||||
const idx = this.children.indexOf(refChild);
|
||||
if (idx >= 0) { this.children.splice(idx, 0, newChild); this.childNodes.splice(idx, 0, newChild); }
|
||||
else { this.children.push(newChild); this.childNodes.push(newChild); }
|
||||
newChild.parentElement = this;
|
||||
newChild.parentNode = this;
|
||||
return newChild;
|
||||
}
|
||||
replaceChild(newChild, oldChild) {
|
||||
const idx = this.children.indexOf(oldChild);
|
||||
if (idx >= 0) { this.children[idx] = newChild; this.childNodes[idx] = newChild; }
|
||||
newChild.parentElement = this;
|
||||
newChild.parentNode = this;
|
||||
oldChild.parentElement = null;
|
||||
oldChild.parentNode = null;
|
||||
return oldChild;
|
||||
}
|
||||
querySelector(sel) { return findInTree(this, sel); }
|
||||
querySelectorAll(sel) { return findAllInTree(this, sel); }
|
||||
closest(sel) {
|
||||
let el = this;
|
||||
while (el) { if (matchesSelector(el, sel)) return el; el = el.parentElement; }
|
||||
return null;
|
||||
}
|
||||
matches(sel) { return matchesSelector(this, sel); }
|
||||
contains(other) {
|
||||
if (other === this) return true;
|
||||
for (const c of this.children) { if (c === other || c.contains(other)) return true; }
|
||||
return false;
|
||||
}
|
||||
cloneNode(deep) {
|
||||
const el = new MockElement(this.tagName.toLowerCase());
|
||||
Object.assign(el.attributes, this.attributes);
|
||||
el.id = this.id;
|
||||
el.className = this.className;
|
||||
el.classList._sync(this.className);
|
||||
Object.assign(el.style, this.style);
|
||||
el.textContent = this.textContent;
|
||||
el.innerHTML = this.innerHTML;
|
||||
if (deep) { for (const c of this.children) el.appendChild(c.cloneNode(true)); }
|
||||
return el;
|
||||
}
|
||||
focus() {}
|
||||
blur() {}
|
||||
click() { this.dispatchEvent(new MockEvent('click', { bubbles: true })); }
|
||||
remove() { if (this.parentElement) this.parentElement.removeChild(this); }
|
||||
get firstElementChild() { return this.children[0] || null; }
|
||||
get lastElementChild() { return this.children[this.children.length - 1] || null; }
|
||||
get nextElementSibling() {
|
||||
if (!this.parentElement) return null;
|
||||
const idx = this.parentElement.children.indexOf(this);
|
||||
return this.parentElement.children[idx + 1] || null;
|
||||
}
|
||||
get previousElementSibling() {
|
||||
if (!this.parentElement) return null;
|
||||
const idx = this.parentElement.children.indexOf(this);
|
||||
return idx > 0 ? this.parentElement.children[idx - 1] : null;
|
||||
}
|
||||
// Dialog methods
|
||||
showModal() { this.open = true; this.setAttribute('open', ''); }
|
||||
show() { this.open = true; this.setAttribute('open', ''); }
|
||||
close() { this.open = false; this.removeAttribute('open'); }
|
||||
// Transition stub
|
||||
getAnimations() { return []; }
|
||||
getBoundingClientRect() { return { top: 0, left: 0, width: 100, height: 100, right: 100, bottom: 100 }; }
|
||||
scrollIntoView() {}
|
||||
}
|
||||
|
||||
class MockClassList {
|
||||
constructor(el) { this._el = el; this._set = new Set(); }
|
||||
_sync(str) { this._set = new Set((str || '').split(/\s+/).filter(Boolean)); }
|
||||
add(...cls) { for (const c of cls) this._set.add(c); this._el.className = [...this._set].join(' '); }
|
||||
remove(...cls) { for (const c of cls) this._set.delete(c); this._el.className = [...this._set].join(' '); }
|
||||
toggle(cls, force) {
|
||||
if (force !== undefined) { if (force) this.add(cls); else this.remove(cls); return force; }
|
||||
if (this._set.has(cls)) { this.remove(cls); return false; }
|
||||
else { this.add(cls); return true; }
|
||||
}
|
||||
contains(cls) { return this._set.has(cls); }
|
||||
get length() { return this._set.size; }
|
||||
[Symbol.iterator]() { return this._set[Symbol.iterator](); }
|
||||
}
|
||||
|
||||
class MockEvent {
|
||||
constructor(type, opts = {}) {
|
||||
this.type = type;
|
||||
this.bubbles = opts.bubbles || false;
|
||||
this.cancelable = opts.cancelable !== false;
|
||||
this.defaultPrevented = false;
|
||||
this._stopped = false;
|
||||
this._stoppedImmediate = false;
|
||||
this.target = null;
|
||||
this.currentTarget = null;
|
||||
this.detail = opts.detail || null;
|
||||
}
|
||||
preventDefault() { this.defaultPrevented = true; }
|
||||
stopPropagation() { this._stopped = true; }
|
||||
stopImmediatePropagation() { this._stopped = true; this._stoppedImmediate = true; }
|
||||
}
|
||||
|
||||
class MockCustomEvent extends MockEvent {
|
||||
constructor(type, opts = {}) { super(type, opts); this.detail = opts.detail || null; }
|
||||
}
|
||||
|
||||
function matchesSelector(el, sel) {
|
||||
if (!el || !el.tagName) return false;
|
||||
if (sel.startsWith('#')) return el.id === sel.slice(1);
|
||||
if (sel.startsWith('.')) return el.classList.contains(sel.slice(1));
|
||||
if (sel.includes('#')) { const [tag, id] = sel.split('#'); return el.tagName.toLowerCase() === tag && el.id === id; }
|
||||
return el.tagName.toLowerCase() === sel.toLowerCase();
|
||||
}
|
||||
|
||||
function findInTree(el, sel) {
|
||||
for (const c of (el.children || [])) {
|
||||
if (matchesSelector(c, sel)) return c;
|
||||
const found = findInTree(c, sel);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findAllInTree(el, sel) {
|
||||
const results = [];
|
||||
for (const c of (el.children || [])) {
|
||||
if (matchesSelector(c, sel)) results.push(c);
|
||||
results.push(...findAllInTree(c, sel));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Global DOM mock ────────────────────────────────────────────
|
||||
const _body = new MockElement('body');
|
||||
const _html = new MockElement('html');
|
||||
_html.appendChild(_body);
|
||||
|
||||
const document = {
|
||||
body: _body,
|
||||
documentElement: _html,
|
||||
createElement(tag) { return new MockElement(tag); },
|
||||
createElementNS(ns, tag) { return new MockElement(tag); },
|
||||
createDocumentFragment() { return new MockElement('fragment'); },
|
||||
createTextNode(text) { const t = { nodeType: 3, textContent: text, data: text }; return t; },
|
||||
getElementById(id) { return findInTree(_body, '#' + id); },
|
||||
querySelector(sel) { return findInTree(_body, sel); },
|
||||
querySelectorAll(sel) { return findAllInTree(_body, sel); },
|
||||
createEvent(type) { return new MockEvent(type); },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
|
||||
globalThis.document = document;
|
||||
globalThis.window = globalThis;
|
||||
globalThis.HTMLElement = MockElement;
|
||||
globalThis.Element = MockElement;
|
||||
globalThis.Event = MockEvent;
|
||||
globalThis.CustomEvent = MockCustomEvent;
|
||||
globalThis.NodeList = Array;
|
||||
globalThis.HTMLCollection = Array;
|
||||
globalThis.getComputedStyle = (el) => el.style;
|
||||
globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
|
||||
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
globalThis.MutationObserver = class { observe() {} disconnect() {} };
|
||||
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
|
||||
globalThis.IntersectionObserver = class { observe() {} disconnect() {} };
|
||||
globalThis.navigator = { userAgent: 'node-test' };
|
||||
globalThis.location = { href: 'http://localhost/', pathname: '/', search: '', hash: '' };
|
||||
globalThis.history = { pushState() {}, replaceState() {}, back() {}, forward() {} };
|
||||
|
||||
// ─── Host FFI ───────────────────────────────────────────────────
|
||||
K.registerNative('host-global', a => { const n = a[0]; return (n in globalThis) ? globalThis[n] : null; });
|
||||
K.registerNative('host-get', a => { if (a[0] == null) return null; const v = a[0][a[1]]; return v === undefined ? null : v; });
|
||||
K.registerNative('host-set!', a => { if (a[0] != null) a[0][a[1]] = a[2]; return a[2]; });
|
||||
K.registerNative('host-call', a => {
|
||||
const [o, m, ...r] = a;
|
||||
if (o == null) { const f = globalThis[m]; return typeof f === 'function' ? f.apply(null, r) : null; }
|
||||
if (typeof o[m] !== 'function') return null;
|
||||
try { const v = o[m].apply(o, r); return v === undefined ? null : v; } catch(e) { return null; }
|
||||
});
|
||||
K.registerNative('host-new', a => {
|
||||
const C = typeof a[0] === 'string' ? globalThis[a[0]] : a[0];
|
||||
return typeof C === 'function' ? new C(...a.slice(1)) : null;
|
||||
});
|
||||
K.registerNative('host-callback', a => {
|
||||
const fn = a[0];
|
||||
if (typeof fn === 'function' && fn.__sx_handle === undefined) return fn;
|
||||
if (fn && fn.__sx_handle !== undefined) {
|
||||
return function() {
|
||||
const r = K.callFn(fn, Array.from(arguments));
|
||||
if (globalThis._driveAsync) globalThis._driveAsync(r);
|
||||
return r;
|
||||
};
|
||||
}
|
||||
return function() {};
|
||||
});
|
||||
K.registerNative('host-typeof', a => {
|
||||
const o = a[0]; if (o == null) return 'nil';
|
||||
if (o instanceof MockElement) return 'element';
|
||||
if (o && o.nodeType === 3) return 'text';
|
||||
if (o instanceof MockEvent || o instanceof MockCustomEvent) return 'event';
|
||||
if (o instanceof Promise) return 'promise';
|
||||
return typeof o;
|
||||
});
|
||||
K.registerNative('host-await', a => {
|
||||
const [p, cb] = a;
|
||||
if (p && typeof p.then === 'function') {
|
||||
const f = (cb && cb.__sx_handle !== undefined) ? v => K.callFn(cb, [v]) : () => {};
|
||||
p.then(f);
|
||||
}
|
||||
});
|
||||
K.registerNative('load-library!', () => false);
|
||||
|
||||
// Drive async suspension — synchronous, with depth limit
|
||||
globalThis._driveAsync = function driveAsync(result, depth) {
|
||||
depth = depth || 0;
|
||||
if (depth > 200) return; // prevent infinite loops
|
||||
if (!result || !result.suspended) return;
|
||||
const req = result.request;
|
||||
const items = req && (req.items || req);
|
||||
const op = items && items[0];
|
||||
const opName = typeof op === 'string' ? op : (op && op.name) || String(op);
|
||||
const arg = items && items[1];
|
||||
function doResume(val) {
|
||||
try { const r = result.resume(val); driveAsync(r, depth + 1); } catch(e) {}
|
||||
}
|
||||
if (opName === 'io-sleep' || opName === 'wait') doResume(null);
|
||||
else if (opName === 'io-fetch') doResume({ ok: true, text: '' });
|
||||
else if (opName === 'io-settle') doResume(null);
|
||||
else if (opName === 'io-wait-event') doResume(null);
|
||||
else if (opName === 'io-transition') doResume(null);
|
||||
};
|
||||
|
||||
K.eval('(define SX_VERSION "hs-test-1.0")');
|
||||
K.eval('(define SX_ENGINE "ocaml-vm-sandbox")');
|
||||
K.eval('(define parse sx-parse)');
|
||||
K.eval('(define serialize sx-serialize)');
|
||||
|
||||
// ─── Load modules ───────────────────────────────────────────────
|
||||
const WEB_MODULES = [
|
||||
'render', 'core-signals', 'signals', 'deps', 'router',
|
||||
'page-helpers', 'freeze', 'dom', 'browser',
|
||||
'adapter-html', 'adapter-sx', 'adapter-dom',
|
||||
'boot-helpers', 'hypersx', 'engine', 'orchestration', 'boot',
|
||||
];
|
||||
const HS_MODULES = ['hs-tokenizer', 'hs-parser', 'hs-compiler', 'hs-runtime', 'hs-integration'];
|
||||
|
||||
K.beginModuleLoad();
|
||||
for (const mod of [...WEB_MODULES, ...HS_MODULES]) {
|
||||
const sxPath = path.join(SX_DIR, mod + '.sx');
|
||||
const libPath = path.join(PROJECT, 'lib/hyperscript', mod.replace(/^hs-/, '') + '.sx');
|
||||
let src;
|
||||
try { src = fs.existsSync(sxPath) ? fs.readFileSync(sxPath, 'utf8') : fs.readFileSync(libPath, 'utf8'); }
|
||||
catch(e) { console.error(`SKIP: ${mod}`); continue; }
|
||||
try { K.load(src); } catch(e) { console.error(`LOAD ERROR: ${mod}: ${e.message}`); }
|
||||
}
|
||||
K.endModuleLoad();
|
||||
|
||||
// ─── Register tests ─────────────────────────────────────────────
|
||||
K.eval('(define _test-registry (list))');
|
||||
K.eval('(define _test-suite "")');
|
||||
K.eval('(define push-suite (fn (name) (set! _test-suite name)))');
|
||||
K.eval('(define pop-suite (fn () (set! _test-suite "")))');
|
||||
K.eval(`(define try-call (fn (thunk)
|
||||
(set! _test-registry (append _test-registry (list {:suite _test-suite :thunk thunk})))
|
||||
{:ok true}))`);
|
||||
K.eval(`(define report-pass (fn (name)
|
||||
(let ((i (- (len _test-registry) 1)))
|
||||
(when (>= i 0) (dict-set! (nth _test-registry i) "name" name)))))`);
|
||||
K.eval(`(define report-fail (fn (name error)
|
||||
(let ((i (- (len _test-registry) 1)))
|
||||
(when (>= i 0) (dict-set! (nth _test-registry i) "name" name)))))`);
|
||||
|
||||
// Load test files
|
||||
for (const f of ['spec/harness.sx', 'spec/tests/test-framework.sx', 'spec/tests/test-hyperscript-behavioral.sx']) {
|
||||
try { K.load(fs.readFileSync(path.join(PROJECT, f), 'utf8')); }
|
||||
catch(e) { console.error(`LOAD ERROR: ${f}: ${e.message}`); }
|
||||
}
|
||||
|
||||
// ─── Run tests ──────────────────────────────────────────────────
|
||||
const testCount = K.eval('(len _test-registry)');
|
||||
console.log(`Loaded ${testCount} tests`);
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
const cats = {};
|
||||
const errTypes = {};
|
||||
const failDetails = [];
|
||||
|
||||
for (let i = 0; i < testCount; i++) {
|
||||
const suite = K.eval(`(get (nth _test-registry ${i}) "suite")`) || '';
|
||||
const name = K.eval(`(get (nth _test-registry ${i}) "name")`) || `test-${i}`;
|
||||
|
||||
// Reset body
|
||||
_body.children = [];
|
||||
_body.childNodes = [];
|
||||
_body.innerHTML = '';
|
||||
|
||||
process.stderr.write(` [${i}/${testCount}] ${suite} > ${name}\n`);
|
||||
|
||||
let ok = false;
|
||||
let err = null;
|
||||
try {
|
||||
const thunk = K.eval(`(get (nth _test-registry ${i}) "thunk")`);
|
||||
if (!thunk) { err = 'no thunk'; }
|
||||
else {
|
||||
const r = K.callFn(thunk, []);
|
||||
if (r && r.suspended) globalThis._driveAsync(r);
|
||||
ok = true;
|
||||
}
|
||||
} catch(e) {
|
||||
err = (e.message || '').slice(0, 150);
|
||||
}
|
||||
|
||||
if (!cats[suite]) cats[suite] = { p: 0, f: 0, errs: [] };
|
||||
if (ok) {
|
||||
passed++;
|
||||
cats[suite].p++;
|
||||
} else {
|
||||
failed++;
|
||||
cats[suite].f++;
|
||||
cats[suite].errs.push({ name, err });
|
||||
|
||||
let t = 'other';
|
||||
if (err === 'TIMEOUT') t = 'timeout';
|
||||
else if (err && err.includes('NOT IMPLEMENTED')) t = 'stub';
|
||||
else if (err && err.includes('Assertion')) t = 'assert-fail';
|
||||
else if (err && err.includes('Expected')) t = 'wrong-value';
|
||||
else if (err && err.includes('Undefined symbol')) t = 'undef-sym';
|
||||
else if (err && err.includes('Unhandled')) t = 'unhandled';
|
||||
errTypes[t] = (errTypes[t] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Report ─────────────────────────────────────────────────────
|
||||
console.log(`\nResults: ${passed}/${passed + failed} (${(100 * passed / (passed + failed)).toFixed(0)}%)\n`);
|
||||
|
||||
console.log('By category:');
|
||||
for (const [cat, s] of Object.entries(cats).sort((a, b) => (b[1].p / (b[1].p + b[1].f)) - (a[1].p / (a[1].p + a[1].f)))) {
|
||||
const mark = s.f === 0 ? `✓ ${s.p}` : `${s.p}/${s.p + s.f}`;
|
||||
console.log(` ${cat}: ${mark}`);
|
||||
}
|
||||
|
||||
console.log('\nFailure types:');
|
||||
for (const [t, n] of Object.entries(errTypes).sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${t}: ${n}`);
|
||||
}
|
||||
|
||||
// Unique errors
|
||||
const uniqueErrors = {};
|
||||
for (const [cat, s] of Object.entries(cats)) {
|
||||
for (const { name, err } of s.errs) {
|
||||
const e = (err || '').slice(0, 100);
|
||||
if (!uniqueErrors[e]) uniqueErrors[e] = 0;
|
||||
uniqueErrors[e]++;
|
||||
}
|
||||
}
|
||||
console.log(`\nUnique errors (${Object.keys(uniqueErrors).length}):`);
|
||||
for (const [e, n] of Object.entries(uniqueErrors).sort((a, b) => b[1] - a[1]).slice(0, 30)) {
|
||||
console.log(` [${n}x] ${e}`);
|
||||
}
|
||||
|
||||
// Show per-category failures for categories with < 5 failures
|
||||
console.log('\nDetailed failures (categories with <10 fails):');
|
||||
for (const [cat, s] of Object.entries(cats).sort((a, b) => a[1].f - b[1].f)) {
|
||||
if (s.f > 0 && s.f < 10) {
|
||||
console.log(` ${cat} (${s.p}/${s.p + s.f}):`);
|
||||
for (const { name, err } of s.errs) {
|
||||
console.log(` FAIL: ${name}: ${(err || '').slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
222
tests/hs-behavioral-runner.js
Normal file
222
tests/hs-behavioral-runner.js
Normal file
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run hyperscript behavioral tests in Node.js with per-test timeout via worker_threads.
|
||||
*/
|
||||
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
if (!isMainThread) {
|
||||
// ─── Worker: run a single test ─────────────────────────────────
|
||||
const { testIdx } = workerData;
|
||||
const PROJECT = path.resolve(__dirname, '..');
|
||||
const WASM_DIR = path.join(PROJECT, 'shared/static/wasm');
|
||||
const SX_DIR = path.join(WASM_DIR, 'sx');
|
||||
|
||||
eval(fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8'));
|
||||
const K = globalThis.SxKernel;
|
||||
|
||||
// Minimal DOM mock
|
||||
class El {
|
||||
constructor(t) { this.tagName=t.toUpperCase(); this.nodeName=this.tagName; this.nodeType=1; this.id=''; this.className=''; this.classList=new CL(this); this.style={}; this.attributes={}; this.children=[]; this.childNodes=[]; this.parentElement=null; this.parentNode=null; this.textContent=''; this.innerHTML=''; this._listeners={}; this.dataset={}; this.open=false; }
|
||||
setAttribute(n,v) { this.attributes[n]=v; if(n==='id')this.id=v; if(n==='class'){this.className=v;this.classList._s(v);} }
|
||||
getAttribute(n) { return this.attributes[n]!==undefined?this.attributes[n]:null; }
|
||||
removeAttribute(n) { delete this.attributes[n]; }
|
||||
hasAttribute(n) { return n in this.attributes; }
|
||||
addEventListener(e,f) { if(!this._listeners[e])this._listeners[e]=[]; this._listeners[e].push(f); }
|
||||
removeEventListener(e,f) { if(this._listeners[e])this._listeners[e]=this._listeners[e].filter(x=>x!==f); }
|
||||
dispatchEvent(ev) { ev.target=this; ev.currentTarget=this; for(const f of (this._listeners[ev.type]||[])){try{f(ev);}catch(e){}} if(ev.bubbles&&!ev._s&&this.parentElement){ev.currentTarget=this.parentElement;this.parentElement.dispatchEvent(ev);} return !ev.defaultPrevented; }
|
||||
appendChild(c) { if(c.parentElement)c.parentElement.removeChild(c); c.parentElement=this; c.parentNode=this; this.children.push(c); this.childNodes.push(c); return c; }
|
||||
removeChild(c) { this.children=this.children.filter(x=>x!==c); this.childNodes=this.childNodes.filter(x=>x!==c); c.parentElement=null; c.parentNode=null; return c; }
|
||||
insertBefore(n,r) { const i=this.children.indexOf(r); if(i>=0){this.children.splice(i,0,n);this.childNodes.splice(i,0,n);}else{this.children.push(n);this.childNodes.push(n);} n.parentElement=this;n.parentNode=this; return n; }
|
||||
replaceChild(n,o) { const i=this.children.indexOf(o); if(i>=0){this.children[i]=n;this.childNodes[i]=n;} n.parentElement=this;n.parentNode=this; o.parentElement=null;o.parentNode=null; return o; }
|
||||
querySelector(s) { return fnd(this,s); }
|
||||
querySelectorAll(s) { return fndAll(this,s); }
|
||||
closest(s) { let e=this; while(e){if(mt(e,s))return e; e=e.parentElement;} return null; }
|
||||
matches(s) { return mt(this,s); }
|
||||
contains(o) { if(o===this)return true; for(const c of this.children)if(c===o||c.contains(o))return true; return false; }
|
||||
cloneNode(d) { const e=new El(this.tagName.toLowerCase()); Object.assign(e.attributes,this.attributes); e.id=this.id; e.className=this.className; e.classList._s(this.className); Object.assign(e.style,this.style); e.textContent=this.textContent; e.innerHTML=this.innerHTML; if(d)for(const c of this.children)e.appendChild(c.cloneNode(true)); return e; }
|
||||
focus(){} blur(){} click(){ this.dispatchEvent(new Ev('click',{bubbles:true})); } remove(){ if(this.parentElement)this.parentElement.removeChild(this); }
|
||||
get firstElementChild() { return this.children[0]||null; }
|
||||
get lastElementChild() { return this.children[this.children.length-1]||null; }
|
||||
get nextElementSibling() { if(!this.parentElement)return null; const i=this.parentElement.children.indexOf(this); return this.parentElement.children[i+1]||null; }
|
||||
get previousElementSibling() { if(!this.parentElement)return null; const i=this.parentElement.children.indexOf(this); return i>0?this.parentElement.children[i-1]:null; }
|
||||
showModal(){this.open=true;this.setAttribute('open','');} show(){this.open=true;} close(){this.open=false;this.removeAttribute('open');}
|
||||
getAnimations(){return [];} getBoundingClientRect(){return{top:0,left:0,width:100,height:100};} scrollIntoView(){}
|
||||
}
|
||||
class CL { constructor(e){this._e=e;this._s=new Set();} _s(str){this._s=new Set((str||'').split(/\s+/).filter(Boolean));} add(...c){for(const x of c)this._s.add(x);this._e.className=[...this._s].join(' ');} remove(...c){for(const x of c)this._s.delete(x);this._e.className=[...this._s].join(' ');} toggle(c,f){if(f!==undefined){if(f)this.add(c);else this.remove(c);return f;} if(this._s.has(c)){this.remove(c);return false;}else{this.add(c);return true;}} contains(c){return this._s.has(c);} get length(){return this._s.size;} }
|
||||
class Ev { constructor(t,o={}){this.type=t;this.bubbles=o.bubbles||false;this.cancelable=o.cancelable!==false;this.defaultPrevented=false;this._s=false;this.target=null;this.currentTarget=null;this.detail=o.detail||null;} preventDefault(){this.defaultPrevented=true;} stopPropagation(){this._s=true;} stopImmediatePropagation(){this._s=true;} }
|
||||
function mt(e,s){if(!e||!e.tagName)return false;if(s.startsWith('#'))return e.id===s.slice(1);if(s.startsWith('.'))return e.classList.contains(s.slice(1));if(s.includes('#')){const[t,i]=s.split('#');return e.tagName.toLowerCase()===t&&e.id===i;} return e.tagName.toLowerCase()===s.toLowerCase();}
|
||||
function fnd(e,s){for(const c of(e.children||[])){if(mt(c,s))return c;const f=fnd(c,s);if(f)return f;}return null;}
|
||||
function fndAll(e,s){const r=[];for(const c of(e.children||[])){if(mt(c,s))r.push(c);r.push(...fndAll(c,s));}return r;}
|
||||
|
||||
const _body=new El('body'); const _html=new El('html'); _html.appendChild(_body);
|
||||
const document={body:_body,documentElement:_html,createElement(t){return new El(t);},createElementNS(n,t){return new El(t);},createDocumentFragment(){return new El('fragment');},createTextNode(t){return{nodeType:3,textContent:t,data:t};},getElementById(i){return fnd(_body,'#'+i);},querySelector(s){return fnd(_body,s);},querySelectorAll(s){return fndAll(_body,s);},createEvent(t){return new Ev(t);},addEventListener(){},removeEventListener(){}};
|
||||
globalThis.document=document; globalThis.window=globalThis; globalThis.HTMLElement=El; globalThis.Element=El; globalThis.Event=Ev; globalThis.CustomEvent=Ev; globalThis.NodeList=Array; globalThis.HTMLCollection=Array;
|
||||
globalThis.getComputedStyle=(e)=>e.style; globalThis.requestAnimationFrame=(f)=>setTimeout(f,0); globalThis.cancelAnimationFrame=(i)=>clearTimeout(i);
|
||||
globalThis.MutationObserver=class{observe(){}disconnect(){}}; globalThis.ResizeObserver=class{observe(){}disconnect(){}}; globalThis.IntersectionObserver=class{observe(){}disconnect(){}};
|
||||
globalThis.navigator={userAgent:'node'}; globalThis.location={href:'http://localhost/',pathname:'/',search:'',hash:''}; globalThis.history={pushState(){},replaceState(){},back(){},forward(){}};
|
||||
|
||||
K.registerNative('host-global',a=>{const n=a[0];return(n in globalThis)?globalThis[n]:null;});
|
||||
K.registerNative('host-get',a=>{if(a[0]==null)return null;const v=a[0][a[1]];return v===undefined?null:v;});
|
||||
K.registerNative('host-set!',a=>{if(a[0]!=null)a[0][a[1]]=a[2];return a[2];});
|
||||
K.registerNative('host-call',a=>{const[o,m,...r]=a;if(o==null){const f=globalThis[m];return typeof f==='function'?f.apply(null,r):null;}if(typeof o[m]!=='function')return null;try{return o[m].apply(o,r)||null;}catch(e){return null;}});
|
||||
K.registerNative('host-new',a=>{const C=typeof a[0]==='string'?globalThis[a[0]]:a[0];return typeof C==='function'?new C(...a.slice(1)):null;});
|
||||
K.registerNative('host-callback',a=>{const fn=a[0];if(typeof fn==='function'&&fn.__sx_handle===undefined)return fn;if(fn&&fn.__sx_handle!==undefined)return function(){const r=K.callFn(fn,Array.from(arguments));if(globalThis._driveAsync)globalThis._driveAsync(r);return r;};return function(){};});
|
||||
K.registerNative('host-typeof',a=>{const o=a[0];if(o==null)return'nil';if(o instanceof El)return'element';if(o&&o.nodeType===3)return'text';if(o instanceof Ev)return'event';if(o instanceof Promise)return'promise';return typeof o;});
|
||||
K.registerNative('host-await',a=>{const[p,cb]=a;if(p&&typeof p.then==='function'){const f=(cb&&cb.__sx_handle!==undefined)?v=>K.callFn(cb,[v]):()=>{};p.then(f);}});
|
||||
K.registerNative('load-library!',()=>false);
|
||||
|
||||
globalThis._driveAsync=function driveAsync(r,d){d=d||0;if(d>200||!r||!r.suspended)return;const req=r.request;const items=req&&(req.items||req);const op=items&&items[0];const opName=typeof op==='string'?op:(op&&op.name)||String(op);function doResume(v){try{const x=r.resume(v);driveAsync(x,d+1);}catch(e){}}if(opName==='io-sleep'||opName==='wait')doResume(null);else if(opName==='io-fetch')doResume({ok:true,text:''});else if(opName==='io-settle')doResume(null);else if(opName==='io-wait-event')doResume(null);else if(opName==='io-transition')doResume(null);};
|
||||
|
||||
K.eval('(define SX_VERSION "hs-test-1.0")');
|
||||
K.eval('(define SX_ENGINE "ocaml-vm-sandbox")');
|
||||
K.eval('(define parse sx-parse)');
|
||||
K.eval('(define serialize sx-serialize)');
|
||||
|
||||
const WEB=['render','core-signals','signals','deps','router','page-helpers','freeze','dom','browser','adapter-html','adapter-sx','adapter-dom','boot-helpers','hypersx','engine','orchestration','boot'];
|
||||
const HS=['hs-tokenizer','hs-parser','hs-compiler','hs-runtime','hs-integration'];
|
||||
K.beginModuleLoad();
|
||||
for(const mod of[...WEB,...HS]){const sp=path.join(SX_DIR,mod+'.sx');const lp=path.join(PROJECT,'lib/hyperscript',mod.replace(/^hs-/,'')+'.sx');let s;try{s=fs.existsSync(sp)?fs.readFileSync(sp,'utf8'):fs.readFileSync(lp,'utf8');}catch(e){continue;}try{K.load(s);}catch(e){}}
|
||||
K.endModuleLoad();
|
||||
|
||||
K.eval('(define _test-registry (list))'); K.eval('(define _test-suite "")');
|
||||
K.eval('(define push-suite (fn (name) (set! _test-suite name)))');
|
||||
K.eval('(define pop-suite (fn () (set! _test-suite "")))');
|
||||
K.eval('(define try-call (fn (thunk) (set! _test-registry (append _test-registry (list {:suite _test-suite :thunk thunk}))) {:ok true}))');
|
||||
K.eval('(define report-pass (fn (name) (let ((i (- (len _test-registry) 1))) (when (>= i 0) (dict-set! (nth _test-registry i) "name" name)))))');
|
||||
K.eval('(define report-fail (fn (name error) (let ((i (- (len _test-registry) 1))) (when (>= i 0) (dict-set! (nth _test-registry i) "name" name)))))');
|
||||
|
||||
for(const f of['spec/harness.sx','spec/tests/test-framework.sx','spec/tests/test-hyperscript-behavioral.sx']){try{K.load(fs.readFileSync(path.join(PROJECT,f),'utf8'));}catch(e){}}
|
||||
|
||||
// Run single test
|
||||
_body.children=[]; _body.childNodes=[]; _body.innerHTML='';
|
||||
const suite=K.eval(`(get (nth _test-registry ${testIdx}) "suite")`)||'';
|
||||
const name=K.eval(`(get (nth _test-registry ${testIdx}) "name")`)||`test-${testIdx}`;
|
||||
let ok=false, err=null;
|
||||
try{
|
||||
const thunk=K.eval(`(get (nth _test-registry ${testIdx}) "thunk")`);
|
||||
if(!thunk){err='no thunk';}else{const r=K.callFn(thunk,[]);if(r&&r.suspended)globalThis._driveAsync(r);ok=true;}
|
||||
}catch(e){err=(e.message||'').slice(0,150);}
|
||||
parentPort.postMessage({suite,name,ok,err});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ─── Main thread ─────────────────────────────────────────────────
|
||||
const PROJECT = path.resolve(__dirname, '..');
|
||||
|
||||
async function runTest(idx) {
|
||||
return new Promise((resolve) => {
|
||||
const worker = new Worker(__filename, { workerData: { testIdx: idx } });
|
||||
const timer = setTimeout(() => { worker.terminate(); resolve({ suite: '', name: `test-${idx}`, ok: false, err: 'TIMEOUT' }); }, 8000);
|
||||
worker.on('message', (msg) => { clearTimeout(timer); resolve(msg); });
|
||||
worker.on('error', (e) => { clearTimeout(timer); resolve({ suite: '', name: `test-${idx}`, ok: false, err: 'CRASH: ' + (e.message || '').slice(0, 80) }); });
|
||||
worker.on('exit', (code) => { if (code !== 0) { clearTimeout(timer); resolve({ suite: '', name: `test-${idx}`, ok: false, err: 'EXIT: ' + code }); } });
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// First, get test count by loading in main thread
|
||||
const WASM_DIR = path.join(PROJECT, 'shared/static/wasm');
|
||||
eval(fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8'));
|
||||
const K = globalThis.SxKernel;
|
||||
K.registerNative('host-global',a=>null);K.registerNative('host-get',a=>null);K.registerNative('host-set!',a=>null);K.registerNative('host-call',a=>null);K.registerNative('host-new',a=>null);K.registerNative('host-callback',a=>function(){});K.registerNative('host-typeof',a=>'nil');K.registerNative('host-await',a=>null);K.registerNative('load-library!',()=>false);
|
||||
K.eval('(define SX_VERSION "hs-test-1.0")');K.eval('(define SX_ENGINE "ocaml-vm-sandbox")');K.eval('(define parse sx-parse)');K.eval('(define serialize sx-serialize)');
|
||||
const SX_DIR=path.join(WASM_DIR,'sx');
|
||||
const WEB=['render','core-signals','signals','deps','router','page-helpers','freeze','dom','browser','adapter-html','adapter-sx','adapter-dom','boot-helpers','hypersx','engine','orchestration','boot'];
|
||||
const HS=['hs-tokenizer','hs-parser','hs-compiler','hs-runtime','hs-integration'];
|
||||
K.beginModuleLoad();
|
||||
for(const mod of[...WEB,...HS]){const sp=path.join(SX_DIR,mod+'.sx');const lp=path.join(PROJECT,'lib/hyperscript',mod.replace(/^hs-/,'')+'.sx');let s;try{s=fs.existsSync(sp)?fs.readFileSync(sp,'utf8'):fs.readFileSync(lp,'utf8');}catch(e){continue;}try{K.load(s);}catch(e){}}
|
||||
K.endModuleLoad();
|
||||
K.eval('(define _test-registry (list))');K.eval('(define _test-suite "")');
|
||||
K.eval('(define push-suite (fn (name) (set! _test-suite name)))');
|
||||
K.eval('(define pop-suite (fn () (set! _test-suite "")))');
|
||||
K.eval('(define try-call (fn (thunk) (set! _test-registry (append _test-registry (list {:suite _test-suite :thunk thunk}))) {:ok true}))');
|
||||
K.eval('(define report-pass (fn (n) true))');K.eval('(define report-fail (fn (n e) true))');
|
||||
for(const f of['spec/harness.sx','spec/tests/test-framework.sx','spec/tests/test-hyperscript-behavioral.sx']){try{K.load(fs.readFileSync(path.join(PROJECT,f),'utf8'));}catch(e){}}
|
||||
|
||||
const testCount = K.eval('(len _test-registry)');
|
||||
// Get names
|
||||
const testNames = [];
|
||||
for (let i = 0; i < testCount; i++) {
|
||||
testNames.push({
|
||||
s: K.eval(`(get (nth _test-registry ${i}) "suite")`) || '',
|
||||
n: K.eval(`(get (nth _test-registry ${i}) "name")`) || `test-${i}`,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Running ${testCount} tests...`);
|
||||
|
||||
// Run tests — 4 workers at a time
|
||||
const results = [];
|
||||
const BATCH = 4;
|
||||
for (let i = 0; i < testCount; i += BATCH) {
|
||||
const batch = [];
|
||||
for (let j = i; j < Math.min(i + BATCH, testCount); j++) {
|
||||
batch.push(runTest(j).then(r => {
|
||||
r.suite = r.suite || testNames[j].s;
|
||||
r.name = r.name || testNames[j].n;
|
||||
return r;
|
||||
}));
|
||||
}
|
||||
const batchResults = await Promise.all(batch);
|
||||
results.push(...batchResults);
|
||||
if ((i + BATCH) % 100 < BATCH) process.stderr.write(` ${Math.min(i + BATCH, testCount)}/${testCount}...\n`);
|
||||
}
|
||||
|
||||
// Tally
|
||||
let passed = 0, failed = 0;
|
||||
const cats = {};
|
||||
const errTypes = {};
|
||||
for (const r of results) {
|
||||
if (!cats[r.suite]) cats[r.suite] = { p: 0, f: 0, errs: [] };
|
||||
if (r.ok) { passed++; cats[r.suite].p++; }
|
||||
else {
|
||||
failed++;
|
||||
cats[r.suite].f++;
|
||||
cats[r.suite].errs.push({ name: r.name, err: r.err });
|
||||
let t = 'other';
|
||||
if (r.err === 'TIMEOUT') t = 'timeout';
|
||||
else if (r.err && r.err.includes('NOT IMPLEMENTED')) t = 'stub';
|
||||
else if (r.err && r.err.includes('Assertion')) t = 'assert-fail';
|
||||
else if (r.err && r.err.includes('Expected')) t = 'wrong-value';
|
||||
else if (r.err && r.err.includes('Undefined symbol')) t = 'undef-sym';
|
||||
else if (r.err && r.err.includes('Unhandled')) t = 'unhandled';
|
||||
else if (r.err && r.err.includes('CRASH')) t = 'crash';
|
||||
else if (r.err && r.err.includes('EXIT')) t = 'exit';
|
||||
errTypes[t] = (errTypes[t] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nResults: ${passed}/${passed + failed} (${(100 * passed / (passed + failed)).toFixed(0)}%)\n`);
|
||||
|
||||
console.log('By category (sorted by pass rate):');
|
||||
for (const [cat, s] of Object.entries(cats).sort((a, b) => {
|
||||
const ra = a[1].p / (a[1].p + a[1].f);
|
||||
const rb = b[1].p / (b[1].p + b[1].f);
|
||||
return rb - ra;
|
||||
})) {
|
||||
const total = s.p + s.f;
|
||||
const pct = (100 * s.p / total).toFixed(0);
|
||||
const mark = s.f === 0 ? `✓ ${s.p}` : `${s.p}/${total} (${pct}%)`;
|
||||
console.log(` ${cat}: ${mark}`);
|
||||
}
|
||||
|
||||
console.log('\nFailure types:');
|
||||
for (const [t, n] of Object.entries(errTypes).sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${t}: ${n}`);
|
||||
}
|
||||
|
||||
const uniqueErrors = {};
|
||||
for (const r of results.filter(r => !r.ok)) {
|
||||
const e = (r.err || '').slice(0, 100);
|
||||
if (!uniqueErrors[e]) uniqueErrors[e] = 0;
|
||||
uniqueErrors[e]++;
|
||||
}
|
||||
console.log(`\nUnique errors (${Object.keys(uniqueErrors).length}):`);
|
||||
for (const [e, n] of Object.entries(uniqueErrors).sort((a, b) => b[1] - a[1]).slice(0, 25)) {
|
||||
console.log(` [${n}x] ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
117
tests/hs-parse-audit.js
Normal file
117
tests/hs-parse-audit.js
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Audit: extract all HS sources from behavioral tests and check parse/compile.
|
||||
* Uses child_process.execSync with timeout to handle hangs.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const PROJECT = path.resolve(__dirname, '..');
|
||||
|
||||
// Extract HS sources from test file with their suite/test names
|
||||
const testSx = fs.readFileSync(path.join(PROJECT, 'spec/tests/test-hyperscript-behavioral.sx'), 'utf8');
|
||||
|
||||
// Extract suites and their test counts from comments
|
||||
const suitePattern = /;; ── (\S+) \((\d+) tests\)/g;
|
||||
const suites = [];
|
||||
let m;
|
||||
while ((m = suitePattern.exec(testSx)) !== null) {
|
||||
suites.push({ name: m[1], count: parseInt(m[2]) });
|
||||
}
|
||||
|
||||
console.log('Test suites (831 total):');
|
||||
let grandTotal = 0;
|
||||
for (const s of suites) {
|
||||
grandTotal += s.count;
|
||||
console.log(` ${s.name}: ${s.count}`);
|
||||
}
|
||||
console.log(` TOTAL: ${grandTotal}`);
|
||||
|
||||
// Categorize tests by type: DOM-action tests vs eval-only tests vs stub tests
|
||||
const lines = testSx.split('\n');
|
||||
let currentSuite = '';
|
||||
let inDeftest = false;
|
||||
let testName = '';
|
||||
let testBody = '';
|
||||
let depth = 0;
|
||||
|
||||
const testCategories = {};
|
||||
|
||||
for (const line of lines) {
|
||||
const suiteMatch = line.match(/\(defsuite\s+"([^"]+)"/);
|
||||
if (suiteMatch) { currentSuite = suiteMatch[1]; continue; }
|
||||
|
||||
const testMatch = line.match(/\(deftest\s+"([^"]+)"/);
|
||||
if (testMatch) { inDeftest = true; testName = testMatch[1]; testBody = line; depth = 1; continue; }
|
||||
|
||||
if (inDeftest) {
|
||||
testBody += '\n' + line;
|
||||
depth += (line.match(/\(/g) || []).length - (line.match(/\)/g) || []).length;
|
||||
if (depth <= 0) {
|
||||
// Categorize this test
|
||||
if (!testCategories[currentSuite]) testCategories[currentSuite] = { domAction: 0, evalOnly: 0, stub: 0, notImpl: 0, names: [] };
|
||||
|
||||
if (testBody.includes('NOT IMPLEMENTED') || testBody.includes('not-implemented')) {
|
||||
testCategories[currentSuite].stub++;
|
||||
} else if (testBody.includes('dom-dispatch') || testBody.includes('dom-set-inner-html') && testBody.includes('hs-activate!')) {
|
||||
testCategories[currentSuite].domAction++;
|
||||
testCategories[currentSuite].names.push(testName);
|
||||
} else if (testBody.includes('eval-hs') || testBody.includes('hs-eval')) {
|
||||
testCategories[currentSuite].evalOnly++;
|
||||
} else {
|
||||
testCategories[currentSuite].domAction++;
|
||||
testCategories[currentSuite].names.push(testName);
|
||||
}
|
||||
|
||||
inDeftest = false;
|
||||
testBody = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count unique HS sources
|
||||
const attrPattern = /dom-set-attr\s+\S+\s+"_"\s+"([^"]+)"/g;
|
||||
const hsSources = new Set();
|
||||
while ((m = attrPattern.exec(testSx)) !== null) {
|
||||
hsSources.add(m[1].replace(/\\"/g, '"'));
|
||||
}
|
||||
|
||||
console.log(`\n${hsSources.size} unique HS sources across ${grandTotal} tests`);
|
||||
|
||||
// Analyze HS source complexity
|
||||
const features = {
|
||||
'on click': 0, 'on load': 0, 'on keyup': 0, 'on mouseenter': 0,
|
||||
'on mutation': 0, 'on intersect': 0, 'on every': 0,
|
||||
'add .': 0, 'remove .': 0, 'toggle .': 0, 'set ': 0, 'put ': 0,
|
||||
'if ': 0, 'repeat': 0, 'wait ': 0, 'send ': 0, 'take ': 0,
|
||||
'transition': 0, 'hide': 0, 'show': 0, 'log': 0,
|
||||
'call ': 0, 'fetch ': 0, 'tell ': 0, 'halt': 0,
|
||||
'morph': 0, 'go ': 0, 'append': 0, 'settle': 0,
|
||||
'def ': 0, 'increment': 0, 'decrement': 0,
|
||||
'bind ': 0, 'closest': 0, 'in ': 0, 'as ': 0,
|
||||
'init': 0, 'every ': 0, 'measure': 0,
|
||||
};
|
||||
|
||||
for (const src of hsSources) {
|
||||
for (const feat of Object.keys(features)) {
|
||||
if (src.includes(feat)) features[feat]++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nFeature usage in test sources:');
|
||||
for (const [feat, count] of Object.entries(features).sort((a,b) => b[1] - a[1])) {
|
||||
if (count > 0) console.log(` ${feat.trim()}: ${count}`);
|
||||
}
|
||||
|
||||
// Summarize categories
|
||||
console.log('\nPer-suite breakdown:');
|
||||
let totalDom = 0, totalEval = 0, totalStub = 0;
|
||||
for (const [suite, cat] of Object.entries(testCategories).sort((a,b) => (b[1].domAction+b[1].evalOnly) - (a[1].domAction+a[1].evalOnly))) {
|
||||
const total = cat.domAction + cat.evalOnly + cat.stub;
|
||||
totalDom += cat.domAction;
|
||||
totalEval += cat.evalOnly;
|
||||
totalStub += cat.stub;
|
||||
console.log(` ${suite}: ${total} tests (${cat.domAction} DOM, ${cat.evalOnly} eval, ${cat.stub} stub)`);
|
||||
}
|
||||
console.log(`\nTotals: ${totalDom} DOM-action, ${totalEval} eval-only, ${totalStub} stubs`);
|
||||
269
tests/hs-run-timed.js
Normal file
269
tests/hs-run-timed.js
Normal file
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run HS behavioral tests with per-test fork-based timeout.
|
||||
* Forks itself for each test to handle infinite loops.
|
||||
*/
|
||||
const { execFileSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PROJECT = path.resolve(__dirname, '..');
|
||||
const WASM_DIR = path.join(PROJECT, 'shared/static/wasm');
|
||||
const SX_DIR = path.join(WASM_DIR, 'sx');
|
||||
|
||||
// If called with --run-single N, run just that one test
|
||||
if (process.argv.includes('--run-single')) {
|
||||
const idx = parseInt(process.argv[process.argv.indexOf('--run-single') + 1]);
|
||||
|
||||
eval(fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8'));
|
||||
const K = globalThis.SxKernel;
|
||||
|
||||
// Minimal DOM mock
|
||||
class El {
|
||||
constructor(t) { this.tagName=t.toUpperCase(); this.nodeName=this.tagName; this.nodeType=1; this.id=''; this.className=''; this.classList=new CL(this); this.style={}; this.attributes={}; this.children=[]; this.childNodes=[]; this.parentElement=null; this.parentNode=null; this.textContent=''; this.innerHTML=''; this._listeners={}; this.dataset={}; this.open=false; this.value=''; this.checked=false; this.disabled=false; this.type=''; this.name=''; this.selectedIndex=-1; this.options=[]; }
|
||||
setAttribute(n,v) { this.attributes[n]=String(v); if(n==='id')this.id=v; if(n==='class'){this.className=v;this.classList._sync(v);} if(n==='value')this.value=v; if(n==='disabled')this.disabled=true; }
|
||||
getAttribute(n) { return this.attributes[n]!==undefined?this.attributes[n]:null; }
|
||||
removeAttribute(n) { delete this.attributes[n]; if(n==='disabled')this.disabled=false; }
|
||||
hasAttribute(n) { return n in this.attributes; }
|
||||
addEventListener(e,f) { if(!this._listeners[e])this._listeners[e]=[]; this._listeners[e].push(f); }
|
||||
removeEventListener(e,f) { if(this._listeners[e])this._listeners[e]=this._listeners[e].filter(x=>x!==f); }
|
||||
dispatchEvent(ev) { ev.target=ev.target||this; ev.currentTarget=this; const fns=[...(this._listeners[ev.type]||[])]; for(const f of fns){if(ev._si)break;try{f.call(this,ev);}catch(e){}} if(ev.bubbles&&!ev._sp&&this.parentElement){this.parentElement.dispatchEvent(ev);} return !ev.defaultPrevented; }
|
||||
appendChild(c) { if(c.parentElement)c.parentElement.removeChild(c); c.parentElement=this; c.parentNode=this; this.children.push(c); this.childNodes.push(c); this._updateText(); return c; }
|
||||
removeChild(c) { this.children=this.children.filter(x=>x!==c); this.childNodes=this.childNodes.filter(x=>x!==c); c.parentElement=null; c.parentNode=null; this._updateText(); return c; }
|
||||
insertBefore(n,r) { if(n.parentElement)n.parentElement.removeChild(n); const i=this.children.indexOf(r); if(i>=0){this.children.splice(i,0,n);this.childNodes.splice(i,0,n);}else{this.children.push(n);this.childNodes.push(n);} n.parentElement=this;n.parentNode=this; return n; }
|
||||
replaceChild(n,o) { const i=this.children.indexOf(o); if(i>=0){this.children[i]=n;this.childNodes[i]=n;} n.parentElement=this;n.parentNode=this; o.parentElement=null;o.parentNode=null; return o; }
|
||||
querySelector(s) { return fnd(this,s); }
|
||||
querySelectorAll(s) { return fndAll(this,s); }
|
||||
closest(s) { let e=this; while(e){if(mt(e,s))return e; e=e.parentElement;} return null; }
|
||||
matches(s) { return mt(this,s); }
|
||||
contains(o) { if(o===this)return true; for(const c of this.children)if(c===o||c.contains(o))return true; return false; }
|
||||
cloneNode(d) { const e=new El(this.tagName.toLowerCase()); Object.assign(e.attributes,this.attributes); e.id=this.id; e.className=this.className; e.classList._sync(this.className); Object.assign(e.style,this.style); e.textContent=this.textContent; e.innerHTML=this.innerHTML; e.value=this.value; if(d)for(const c of this.children)e.appendChild(c.cloneNode(true)); return e; }
|
||||
focus(){} blur(){} click(){this.dispatchEvent(new Ev('click',{bubbles:true}));} remove(){if(this.parentElement)this.parentElement.removeChild(this);}
|
||||
_updateText() {}
|
||||
get firstElementChild() { return this.children[0]||null; }
|
||||
get lastElementChild() { return this.children[this.children.length-1]||null; }
|
||||
get nextElementSibling() { if(!this.parentElement)return null; const i=this.parentElement.children.indexOf(this); return this.parentElement.children[i+1]||null; }
|
||||
get previousElementSibling() { if(!this.parentElement)return null; const i=this.parentElement.children.indexOf(this); return i>0?this.parentElement.children[i-1]:null; }
|
||||
showModal(){this.open=true;this.setAttribute('open','');} show(){this.open=true;} close(){this.open=false;this.removeAttribute('open');}
|
||||
getAnimations(){return [];} getBoundingClientRect(){return{top:0,left:0,width:100,height:100,right:100,bottom:100};} scrollIntoView(){}
|
||||
get ownerDocument() { return document; }
|
||||
get offsetParent() { return this.parentElement; }
|
||||
get offsetTop() { return 0; } get offsetLeft() { return 0; }
|
||||
get scrollTop() { return 0; } set scrollTop(v) {} get scrollLeft() { return 0; } set scrollLeft(v) {}
|
||||
get scrollHeight() { return 100; } get scrollWidth() { return 100; }
|
||||
get clientHeight() { return 100; } get clientWidth() { return 100; }
|
||||
insertAdjacentHTML(pos, html) {
|
||||
const el = parseHTMLFragment(html);
|
||||
if (pos === 'beforeend' || pos === 'beforeEnd') this.appendChild(el);
|
||||
else if (pos === 'afterbegin' || pos === 'afterBegin') { if (this.children.length) this.insertBefore(el, this.children[0]); else this.appendChild(el); }
|
||||
else if (pos === 'beforebegin' || pos === 'beforeBegin') { if (this.parentElement) this.parentElement.insertBefore(el, this); }
|
||||
else if (pos === 'afterend' || pos === 'afterEnd') { if (this.parentElement) { const i = this.parentElement.children.indexOf(this); if (i >= 0 && i < this.parentElement.children.length - 1) this.parentElement.insertBefore(el, this.parentElement.children[i+1]); else this.parentElement.appendChild(el); } }
|
||||
}
|
||||
}
|
||||
class CL { constructor(e){this._el=e;this._set=new Set();} _sync(str){this._set=new Set((str||'').split(/\s+/).filter(Boolean));} add(...c){for(const x of c)this._set.add(x);this._el.className=[...this._set].join(' ');this._el.attributes['class']=this._el.className;} remove(...c){for(const x of c)this._set.delete(x);this._el.className=[...this._set].join(' ');this._el.attributes['class']=this._el.className;} toggle(c,f){if(f!==undefined){if(f)this.add(c);else this.remove(c);return f;} if(this._set.has(c)){this.remove(c);return false;}else{this.add(c);return true;}} contains(c){return this._set.has(c);} get length(){return this._set.size;} [Symbol.iterator](){return this._set[Symbol.iterator]();} }
|
||||
class Ev { constructor(t,o={}){this.type=t;this.bubbles=o.bubbles||false;this.cancelable=o.cancelable!==false;this.defaultPrevented=false;this._sp=false;this._si=false;this.target=null;this.currentTarget=null;this.detail=o.detail||null;} preventDefault(){this.defaultPrevented=true;} stopPropagation(){this._sp=true;} stopImmediatePropagation(){this._sp=true;this._si=true;} }
|
||||
|
||||
function parseHTMLFragment(html) {
|
||||
// Minimal HTML parser for test setup
|
||||
const el = new El('div');
|
||||
const m = html.match(/^<(\w+)([^>]*)>([\s\S]*?)<\/\1>$/);
|
||||
if (m) {
|
||||
const tag = m[1]; const attrs = m[2]; const inner = m[3];
|
||||
const child = new El(tag);
|
||||
const attrRe = /(\w[\w-]*)="([^"]*)"/g; let am;
|
||||
while ((am = attrRe.exec(attrs))) child.setAttribute(am[1], am[2]);
|
||||
if (inner) { child.textContent = inner; child.innerHTML = inner; }
|
||||
return child;
|
||||
}
|
||||
el.innerHTML = html; el.textContent = html;
|
||||
return el;
|
||||
}
|
||||
|
||||
function mt(e,s) {
|
||||
if(!e||!e.tagName)return false;
|
||||
s = s.trim();
|
||||
if(s.startsWith('#'))return e.id===s.slice(1);
|
||||
if(s.startsWith('.'))return e.classList.contains(s.slice(1));
|
||||
if(s.startsWith('[')) {
|
||||
const m = s.match(/^\[([^\]=]+)(?:="([^"]*)")?\]$/);
|
||||
if(m) return m[2] !== undefined ? e.getAttribute(m[1]) === m[2] : e.hasAttribute(m[1]);
|
||||
}
|
||||
if(s.includes('.')) { const [tag, cls] = s.split('.'); return e.tagName.toLowerCase() === tag && e.classList.contains(cls); }
|
||||
if(s.includes('#')) { const [tag, id] = s.split('#'); return e.tagName.toLowerCase() === tag && e.id === id; }
|
||||
return e.tagName.toLowerCase() === s.toLowerCase();
|
||||
}
|
||||
function fnd(e,s) { for(const c of(e.children||[])){if(mt(c,s))return c;const f=fnd(c,s);if(f)return f;} return null; }
|
||||
function fndAll(e,s) { const r=[];for(const c of(e.children||[])){if(mt(c,s))r.push(c);r.push(...fndAll(c,s));}return r; }
|
||||
|
||||
const _body = new El('body');
|
||||
const _html = new El('html'); _html.appendChild(_body);
|
||||
const document = {
|
||||
body: _body, documentElement: _html,
|
||||
createElement(t){return new El(t);}, createElementNS(n,t){return new El(t);},
|
||||
createDocumentFragment(){const f=new El('fragment');f.nodeType=11;return f;},
|
||||
createTextNode(t){return{nodeType:3,textContent:t,data:t};},
|
||||
getElementById(i){return fnd(_body,'#'+i);},
|
||||
querySelector(s){return fnd(_body,s);}, querySelectorAll(s){return fndAll(_body,s);},
|
||||
createEvent(t){return new Ev(t);}, addEventListener(){}, removeEventListener(){},
|
||||
};
|
||||
globalThis.document=document; globalThis.window=globalThis; globalThis.HTMLElement=El; globalThis.Element=El;
|
||||
globalThis.Event=Ev; globalThis.CustomEvent=Ev; globalThis.NodeList=Array; globalThis.HTMLCollection=Array;
|
||||
globalThis.getComputedStyle=(e)=>e?e.style:{}; globalThis.requestAnimationFrame=(f)=>{f();return 0;};
|
||||
globalThis.cancelAnimationFrame=()=>{}; globalThis.MutationObserver=class{observe(){}disconnect(){}};
|
||||
globalThis.ResizeObserver=class{observe(){}disconnect(){}}; globalThis.IntersectionObserver=class{observe(){}disconnect(){}};
|
||||
globalThis.navigator={userAgent:'node'}; globalThis.location={href:'http://localhost/',pathname:'/',search:'',hash:''};
|
||||
globalThis.history={pushState(){},replaceState(){},back(){},forward(){}};
|
||||
globalThis.console = { ...console, error: () => {}, warn: () => {} };
|
||||
|
||||
K.registerNative('host-global',a=>{const n=a[0];return(n in globalThis)?globalThis[n]:null;});
|
||||
K.registerNative('host-get',a=>{if(a[0]==null)return null;const v=a[0][a[1]];return v===undefined?null:v;});
|
||||
K.registerNative('host-set!',a=>{if(a[0]!=null)a[0][a[1]]=a[2];return a[2];});
|
||||
K.registerNative('host-call',a=>{const[o,m,...r]=a;if(o==null){const f=globalThis[m];return typeof f==='function'?f.apply(null,r):null;}if(o&&typeof o[m]==='function'){try{const v=o[m].apply(o,r);return v===undefined?null:v;}catch(e){return null;}}return null;});
|
||||
K.registerNative('host-new',a=>{const C=typeof a[0]==='string'?globalThis[a[0]]:a[0];return typeof C==='function'?new C(...a.slice(1)):null;});
|
||||
K.registerNative('host-callback',a=>{const fn=a[0];if(typeof fn==='function'&&fn.__sx_handle===undefined)return fn;if(fn&&fn.__sx_handle!==undefined)return function(){const r=K.callFn(fn,Array.from(arguments));if(globalThis._driveAsync)globalThis._driveAsync(r);return r;};return function(){};});
|
||||
K.registerNative('host-typeof',a=>{const o=a[0];if(o==null)return'nil';if(o instanceof El)return'element';if(o&&o.nodeType===3)return'text';if(o instanceof Ev)return'event';if(o instanceof Promise)return'promise';return typeof o;});
|
||||
K.registerNative('host-await',a=>{});
|
||||
K.registerNative('load-library!',()=>false);
|
||||
|
||||
globalThis._driveAsync=function driveAsync(r,d){d=d||0;if(d>500||!r||!r.suspended)return;const req=r.request;const items=req&&(req.items||req);const op=items&&items[0];const opName=typeof op==='string'?op:(op&&op.name)||String(op);function doResume(v){try{const x=r.resume(v);driveAsync(x,d+1);}catch(e){}}if(opName==='io-sleep'||opName==='wait')doResume(null);else if(opName==='io-fetch')doResume({ok:true,text:''});else if(opName==='io-settle')doResume(null);else if(opName==='io-wait-event')doResume(null);else if(opName==='io-transition')doResume(null);};
|
||||
|
||||
K.eval('(define SX_VERSION "hs-test-1.0")');K.eval('(define SX_ENGINE "ocaml-vm-sandbox")');
|
||||
K.eval('(define parse sx-parse)');K.eval('(define serialize sx-serialize)');
|
||||
|
||||
const WEB=['render','core-signals','signals','deps','router','page-helpers','freeze','dom','browser','adapter-html','adapter-sx','adapter-dom','boot-helpers','hypersx','engine','orchestration','boot'];
|
||||
const HS=['hs-tokenizer','hs-parser','hs-compiler','hs-runtime','hs-integration'];
|
||||
K.beginModuleLoad();
|
||||
for(const mod of[...WEB,...HS]){const sp=path.join(SX_DIR,mod+'.sx');const lp=path.join(PROJECT,'lib/hyperscript',mod.replace(/^hs-/,'')+'.sx');let s;try{s=fs.existsSync(sp)?fs.readFileSync(sp,'utf8'):fs.readFileSync(lp,'utf8');}catch(e){continue;}try{K.load(s);}catch(e){process.stderr.write(`LOAD ERROR: ${mod}: ${e.message}\n`);}}
|
||||
K.endModuleLoad();
|
||||
|
||||
K.eval('(define _test-registry (list))');K.eval('(define _test-suite "")');
|
||||
K.eval('(define push-suite (fn (name) (set! _test-suite name)))');
|
||||
K.eval('(define pop-suite (fn () (set! _test-suite "")))');
|
||||
K.eval('(define try-call (fn (thunk) (set! _test-registry (append _test-registry (list {:suite _test-suite :thunk thunk}))) {:ok true}))');
|
||||
K.eval('(define report-pass (fn (n) true))');
|
||||
K.eval('(define report-fail (fn (n e) true))');
|
||||
for(const f of['spec/harness.sx','spec/tests/test-framework.sx','spec/tests/test-hyperscript-behavioral.sx']){
|
||||
try{K.load(fs.readFileSync(path.join(PROJECT,f),'utf8'));}catch(e){process.stderr.write(`TEST LOAD ERROR: ${f}: ${e.message}\n`);}}
|
||||
|
||||
const thunk=K.eval(`(get (nth _test-registry ${idx}) "thunk")`);
|
||||
const suite=K.eval(`(get (nth _test-registry ${idx}) "suite")`)||'';
|
||||
if(!thunk){process.stdout.write(JSON.stringify({ok:false,err:'no thunk',suite}));process.exit(0);}
|
||||
try{const r=K.callFn(thunk,[]);if(r&&r.suspended)globalThis._driveAsync(r);process.stdout.write(JSON.stringify({ok:true,suite}));}
|
||||
catch(e){process.stdout.write(JSON.stringify({ok:false,err:(e.message||'').slice(0,200),suite}));}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ─── Main process ────────────────────────────────────────────────
|
||||
// First pass: load kernel to get test count and names
|
||||
eval(fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8'));
|
||||
const K = globalThis.SxKernel;
|
||||
K.registerNative('host-global',a=>null);K.registerNative('host-get',a=>null);
|
||||
K.registerNative('host-set!',a=>null);K.registerNative('host-call',a=>null);
|
||||
K.registerNative('host-new',a=>null);K.registerNative('host-callback',a=>function(){});
|
||||
K.registerNative('host-typeof',a=>'nil');K.registerNative('host-await',a=>null);
|
||||
K.registerNative('load-library!',()=>false);
|
||||
K.eval('(define SX_VERSION "hs-test-1.0")');K.eval('(define SX_ENGINE "ocaml-vm-sandbox")');
|
||||
K.eval('(define parse sx-parse)');K.eval('(define serialize sx-serialize)');
|
||||
const WEB=['render','core-signals','signals','deps','router','page-helpers','freeze','dom','browser','adapter-html','adapter-sx','adapter-dom','boot-helpers','hypersx','engine','orchestration','boot'];
|
||||
const HS=['hs-tokenizer','hs-parser','hs-compiler','hs-runtime','hs-integration'];
|
||||
K.beginModuleLoad();
|
||||
for(const mod of[...WEB,...HS]){const sp=path.join(SX_DIR,mod+'.sx');const lp=path.join(PROJECT,'lib/hyperscript',mod.replace(/^hs-/,'')+'.sx');let s;try{s=fs.existsSync(sp)?fs.readFileSync(sp,'utf8'):fs.readFileSync(lp,'utf8');}catch(e){continue;}try{K.load(s);}catch(e){}}
|
||||
K.endModuleLoad();
|
||||
K.eval('(define _test-registry (list))');K.eval('(define _test-suite "")');
|
||||
K.eval('(define push-suite (fn (name) (set! _test-suite name)))');
|
||||
K.eval('(define pop-suite (fn () (set! _test-suite "")))');
|
||||
K.eval('(define try-call (fn (thunk) (set! _test-registry (append _test-registry (list {:suite _test-suite :thunk thunk}))) {:ok true}))');
|
||||
K.eval('(define report-pass (fn (n) true))');K.eval('(define report-fail (fn (n e) true))');
|
||||
for(const f of['spec/harness.sx','spec/tests/test-framework.sx','spec/tests/test-hyperscript-behavioral.sx']){try{K.load(fs.readFileSync(path.join(PROJECT,f),'utf8'));}catch(e){}}
|
||||
|
||||
const testCount = K.eval('(len _test-registry)');
|
||||
const names = [];
|
||||
for(let i=0;i<testCount;i++) names.push({
|
||||
s: K.eval(`(get (nth _test-registry ${i}) "suite")`)||'',
|
||||
n: K.eval(`(get (nth _test-registry ${i}) "name")`)||`test-${i}`,
|
||||
});
|
||||
|
||||
const TIMEOUT_MS = parseInt(process.env.HS_TIMEOUT || '8000');
|
||||
const CONCURRENCY = parseInt(process.env.HS_CONCURRENCY || '6');
|
||||
const startFrom = parseInt(process.argv[2] || '0');
|
||||
const endAt = parseInt(process.argv[3] || String(testCount));
|
||||
|
||||
console.log(`Running tests ${startFrom}-${endAt-1} of ${testCount} (timeout=${TIMEOUT_MS}ms, concurrency=${CONCURRENCY})...`);
|
||||
|
||||
let passed=0,failed=0;
|
||||
const cats={};const errTypes={};
|
||||
|
||||
async function runTest(i) {
|
||||
return new Promise((resolve) => {
|
||||
const child = require('child_process').fork(__filename, ['--run-single', String(i)], {
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
let stdout = '';
|
||||
child.stdout.on('data', d => stdout += d);
|
||||
child.on('close', (code, signal) => {
|
||||
if (signal === 'SIGTERM' || code === null) {
|
||||
resolve({ ok: false, err: 'TIMEOUT', suite: names[i].s });
|
||||
} else {
|
||||
try { resolve(JSON.parse(stdout)); }
|
||||
catch(e) { resolve({ ok: false, err: stdout.slice(0,150) || `exit ${code}`, suite: names[i].s }); }
|
||||
}
|
||||
});
|
||||
child.on('error', (e) => {
|
||||
resolve({ ok: false, err: 'FORK: ' + (e.message||'').slice(0,100), suite: names[i].s });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Run in batches
|
||||
for (let i = startFrom; i < endAt; i += CONCURRENCY) {
|
||||
const batch = [];
|
||||
for (let j = i; j < Math.min(i + CONCURRENCY, endAt); j++) batch.push(j);
|
||||
const results = await Promise.all(batch.map(runTest));
|
||||
|
||||
for (let k = 0; k < results.length; k++) {
|
||||
const r = results[k];
|
||||
const idx = batch[k];
|
||||
const suite = r.suite || names[idx].s;
|
||||
if (!cats[suite]) cats[suite] = { p: 0, f: 0, errs: [] };
|
||||
|
||||
if (r.ok) { passed++; cats[suite].p++; }
|
||||
else {
|
||||
failed++; cats[suite].f++; cats[suite].errs.push({ name: names[idx].n, err: r.err });
|
||||
let t = 'other';
|
||||
if (r.err === 'TIMEOUT') t = 'timeout';
|
||||
else if (r.err && r.err.includes('NOT IMPLEMENTED')) t = 'stub';
|
||||
else if (r.err && r.err.includes('Assertion')) t = 'assert-fail';
|
||||
else if (r.err && r.err.includes('Expected')) t = 'wrong-value';
|
||||
else if (r.err && r.err.includes('Undefined symbol')) t = 'undef-sym';
|
||||
else if (r.err && r.err.includes('Unhandled')) t = 'unhandled';
|
||||
errTypes[t] = (errTypes[t] || 0) + 1;
|
||||
}
|
||||
}
|
||||
if ((i + CONCURRENCY) % 50 < CONCURRENCY) {
|
||||
process.stderr.write(` ${Math.min(i + CONCURRENCY, endAt)}/${endAt} (${passed} pass)...\n`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nResults: ${passed}/${passed+failed} (${(100*passed/(passed+failed)).toFixed(0)}%)\n`);
|
||||
|
||||
console.log('By category:');
|
||||
for (const [cat, s] of Object.entries(cats).sort((a, b) => {
|
||||
const ra = a[1].p / (a[1].p + a[1].f); const rb = b[1].p / (b[1].p + b[1].f); return rb - ra;
|
||||
})) {
|
||||
const total = s.p + s.f; const pct = (100 * s.p / total).toFixed(0);
|
||||
const mark = s.f === 0 ? `\u2713 ${s.p}` : `${s.p}/${total} (${pct}%)`;
|
||||
console.log(` ${cat}: ${mark}`);
|
||||
}
|
||||
|
||||
console.log('\nFailure types:');
|
||||
for (const [t, n] of Object.entries(errTypes).sort((a, b) => b[1] - a[1])) console.log(` ${t}: ${n}`);
|
||||
|
||||
const ue = {};
|
||||
for (const [cat, s] of Object.entries(cats)) for (const { err } of s.errs) { const e = (err || '').slice(0, 120); ue[e] = (ue[e] || 0) + 1; }
|
||||
console.log(`\nUnique errors (${Object.keys(ue).length}):`);
|
||||
for (const [e, n] of Object.entries(ue).sort((a, b) => b[1] - a[1]).slice(0, 30)) console.log(` [${n}x] ${e}`);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user