HS generator+runtime: nth() dispatch+expect, dom-query-all SX-list passthrough, nth-of-type selector

- generate-sx-tests.py: add_action/add_assertion accept .nth(N) in PW-body
  tests so 'find(sel).nth(1).dispatchEvent(...)' lands as a dispatch on
  the Nth matching element, and assertions target that same element.
- shared/static/wasm/sx/dom.sx: dom-query-all hands through an already-SX
  list unchanged — the bridge often pre-converts NodeLists/arrays to SX
  lists, so the host-get 'length' / host-call 'item' loop was returning
  empty. Guards node-list=nil and non-list types too.
- tests/hs-run-filtered.js (mock DOM): fnd() understands
  ':nth-of-type(N)', ':first-of-type', ':last-of-type' by matching the
  stripped base selector and returning the correct-indexed sibling.
  Covers upstream tests that write 'find("div:nth-of-type(2)")' to
  pick the HS-owning element.
- Runtime runtime.sx: hs-sorted-by, hs-fetch format normalizer (JSON/
  Object/etc.), nil-safe hs-joined-by/hs-split-by, emit-fetch chain sets
  the-result when wrapped in let((it …)).

Net: take 0→6, hide 11→12, show 15→16, fetch 11→15,
collectionExpressions 13→15 (remaining are a WASM JIT bug on
{…} literals inside arrays).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 16:41:47 +00:00
parent 5b100cac17
commit 3528cef35a
6 changed files with 150 additions and 30 deletions

View File

@@ -1803,9 +1803,35 @@
(fn (p) (nth p 1)) (fn (p) (nth p 1))
(sort (fn (a b) (if (> (first a) (first b)) true false)) pairs))))) (sort (fn (a b) (if (> (first a) (first b)) true false)) pairs)))))
(define hs-split-by (fn (s sep) (split s sep))) (define hs-split-by (fn (s sep) (if (nil? s) nil (split s sep))))
(define hs-joined-by (fn (col sep) (join sep col))) (define
hs-joined-by
(fn
(col sep)
(cond
((nil? col) nil)
((list? col)
(join sep (map (fn (x) (if (nil? x) "" (str x))) col)))
(true col))))
(define
hs-sorted-by
(fn
(coll key-fn)
(if
(not (list? coll))
coll
(sort
(fn
(a b)
(let
((ka (key-fn a)) (kb (key-fn b)))
(cond
((nil? ka) (not (nil? kb)))
((nil? kb) false)
(true (< ka kb)))))
coll))))
(define (define
hs-sorted-by hs-sorted-by

View File

@@ -195,19 +195,22 @@
"Query DOM and return an SX list (not a host NodeList)." "Query DOM and return an SX list (not a host NodeList)."
(let (let
((node-list (if (nil? sel) (host-call (dom-document) "querySelectorAll" root) (host-call root "querySelectorAll" sel)))) ((node-list (if (nil? sel) (host-call (dom-document) "querySelectorAll" root) (host-call root "querySelectorAll" sel))))
(if (cond
(nil? node-list) ((nil? node-list) (list))
(list) ((list? node-list) node-list)
(let (true
((n (host-get node-list "length")) (result (list)))
(let (let
loop ((n (host-get node-list "length")) (result (list)))
((i 0))
(when (when
(< i n) (not (nil? n))
(append! result (host-call node-list "item" i)) (let
(loop (+ i 1)))) loop
result))))) ((i 0))
(when
(< i n)
(append! result (host-call node-list "item" i))
(loop (+ i 1)))))
result))))))
(define (define
dom-query-by-id dom-query-by-id
(fn (id) (host-call (dom-document) "getElementById" id))) (fn (id) (host-call (dom-document) "getElementById" id)))

View File

@@ -1803,9 +1803,35 @@
(fn (p) (nth p 1)) (fn (p) (nth p 1))
(sort (fn (a b) (if (> (first a) (first b)) true false)) pairs))))) (sort (fn (a b) (if (> (first a) (first b)) true false)) pairs)))))
(define hs-split-by (fn (s sep) (split s sep))) (define hs-split-by (fn (s sep) (if (nil? s) nil (split s sep))))
(define hs-joined-by (fn (col sep) (join sep col))) (define
hs-joined-by
(fn
(col sep)
(cond
((nil? col) nil)
((list? col)
(join sep (map (fn (x) (if (nil? x) "" (str x))) col)))
(true col))))
(define
hs-sorted-by
(fn
(coll key-fn)
(if
(not (list? coll))
coll
(sort
(fn
(a b)
(let
((ka (key-fn a)) (kb (key-fn b)))
(cond
((nil? ka) (not (nil? kb)))
((nil? kb) false)
(true (< ka kb)))))
coll))))
(define (define
hs-sorted-by hs-sorted-by

View File

@@ -1309,7 +1309,10 @@
(dom-append (dom-body) _el-div1) (dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2) (dom-append (dom-body) _el-div2)
(hs-activate! _el-div1) (hs-activate! _el-div1)
(dom-dispatch (nth (dom-query-all (dom-body) ".divs") 1) "click" nil)
(assert (not (dom-has-class? (dom-query ".divs") "foo"))) (assert (not (dom-has-class? (dom-query ".divs") "foo")))
(assert (dom-has-class? (nth (dom-query-all (dom-body) ".divs") 1) "foo"))
(assert (not (dom-has-class? (nth (dom-query-all (dom-body) ".divs") 2) "foo")))
)) ))
(deftest "can target another div" (deftest "can target another div"
(hs-cleanup!) (hs-cleanup!)
@@ -10816,6 +10819,7 @@
(hs-activate! _el-div) (hs-activate! _el-div)
(dom-dispatch (dom-query ".divs") "click" nil) (dom-dispatch (dom-query ".divs") "click" nil)
(assert= (dom-text-content (dom-query ".divs")) "foo") (assert= (dom-text-content (dom-query ".divs")) "foo")
(assert= (dom-text-content (nth (dom-query-all (dom-body) ".divs") 1)) "foo")
)) ))
(deftest "can set into id ref" (deftest "can set into id ref"
(hs-cleanup!) (hs-cleanup!)
@@ -11035,6 +11039,7 @@
(hs-activate! _el-trigger) (hs-activate! _el-trigger)
(dom-dispatch (dom-query-by-id "trigger") "click" nil) (dom-dispatch (dom-query-by-id "trigger") "click" nil)
(assert (dom-has-class? (dom-query ".item") "done")) (assert (dom-has-class? (dom-query ".item") "done"))
(assert (dom-has-class? (nth (dom-query-all (dom-body) ".item") 1) "done"))
)) ))
(deftest "can settle me no transition" (deftest "can settle me no transition"
(hs-cleanup!) (hs-cleanup!)
@@ -11467,8 +11472,12 @@
(dom-append (dom-body) _el-div1) (dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2) (dom-append (dom-body) _el-div2)
(hs-activate! _el-div1) (hs-activate! _el-div1)
(dom-dispatch (nth (dom-query-all (dom-body) "div") 1) "click" nil)
(assert (dom-has-class? _el-div "unselected")) (assert (dom-has-class? _el-div "unselected"))
(assert (not (dom-has-class? _el-div "\bselected\b"))) (assert (not (dom-has-class? _el-div "\bselected\b")))
(assert (dom-has-class? (nth (dom-query-all (dom-body) "div") 1) "\bselected\b"))
(assert (not (dom-has-class? (nth (dom-query-all (dom-body) "div") 1) "unselected")))
(assert (dom-has-class? (nth (dom-query-all (dom-body) "div") 2) "unselected"))
)) ))
(deftest "can take a class and swap it with another via with" (deftest "can take a class and swap it with another via with"
(hs-cleanup!) (hs-cleanup!)
@@ -11482,8 +11491,12 @@
(dom-append (dom-body) _el-div1) (dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2) (dom-append (dom-body) _el-div2)
(hs-activate! _el-div1) (hs-activate! _el-div1)
(dom-dispatch (nth (dom-query-all (dom-body) "div") 1) "click" nil)
(assert (not (dom-has-class? _el-div "\bselected\b"))) (assert (not (dom-has-class? _el-div "\bselected\b")))
(assert (dom-has-class? _el-div "unselected")) (assert (dom-has-class? _el-div "unselected"))
(assert (dom-has-class? (nth (dom-query-all (dom-body) "div") 1) "\bselected\b"))
(assert (not (dom-has-class? (nth (dom-query-all (dom-body) "div") 1) "unselected")))
(assert (dom-has-class? (nth (dom-query-all (dom-body) "div") 2) "unselected"))
)) ))
(deftest "can take a class for other elements" (deftest "can take a class for other elements"
(hs-cleanup!) (hs-cleanup!)
@@ -11498,7 +11511,9 @@
(dom-append (dom-body) _el-div1) (dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-d3) (dom-append (dom-body) _el-d3)
(hs-activate! _el-div1) (hs-activate! _el-div1)
(dom-dispatch (nth (dom-query-all (dom-body) "div") 1) "click" nil)
(assert (not (dom-has-class? _el-div "foo"))) (assert (not (dom-has-class? _el-div "foo")))
(assert (not (dom-has-class? (nth (dom-query-all (dom-body) "div") 1) "foo")))
(assert (dom-has-class? (dom-query-by-id "d3") "foo")) (assert (dom-has-class? (dom-query-by-id "d3") "foo"))
)) ))
(deftest "can take a class from other elements" (deftest "can take a class from other elements"
@@ -11513,7 +11528,10 @@
(dom-append (dom-body) _el-div1) (dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2) (dom-append (dom-body) _el-div2)
(hs-activate! _el-div1) (hs-activate! _el-div1)
(dom-dispatch (nth (dom-query-all (dom-body) "div") 1) "click" nil)
(assert (not (dom-has-class? _el-div "foo"))) (assert (not (dom-has-class? _el-div "foo")))
(assert (dom-has-class? (nth (dom-query-all (dom-body) "div") 1) "foo"))
(assert (not (dom-has-class? (nth (dom-query-all (dom-body) "div") 2) "foo")))
)) ))
(deftest "can take a class from other forms" (deftest "can take a class from other forms"
(hs-cleanup!) (hs-cleanup!)
@@ -11527,7 +11545,10 @@
(dom-append (dom-body) _el-form1) (dom-append (dom-body) _el-form1)
(dom-append (dom-body) _el-form2) (dom-append (dom-body) _el-form2)
(hs-activate! _el-form1) (hs-activate! _el-form1)
(dom-dispatch (nth (dom-query-all (dom-body) "form") 1) "click" nil)
(assert (not (dom-has-class? _el-form "foo"))) (assert (not (dom-has-class? _el-form "foo")))
(assert (dom-has-class? (nth (dom-query-all (dom-body) "form") 1) "foo"))
(assert (not (dom-has-class? (nth (dom-query-all (dom-body) "form") 2) "foo")))
)) ))
(deftest "can take an attribute for other elements" (deftest "can take an attribute for other elements"
(hs-cleanup!) (hs-cleanup!)
@@ -11542,7 +11563,9 @@
(dom-append (dom-body) _el-div1) (dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-d3) (dom-append (dom-body) _el-d3)
(hs-activate! _el-div1) (hs-activate! _el-div1)
(dom-dispatch (nth (dom-query-all (dom-body) "div") 1) "click" nil)
(assert (not (dom-has-attr? _el-div "data-foo"))) (assert (not (dom-has-attr? _el-div "data-foo")))
(assert (not (dom-has-attr? (nth (dom-query-all (dom-body) "div") 1) "data-foo")))
(assert= (dom-get-attr (dom-query-by-id "d3") "data-foo") "") (assert= (dom-get-attr (dom-query-by-id "d3") "data-foo") "")
)) ))
(deftest "can take an attribute from other elements" (deftest "can take an attribute from other elements"
@@ -11558,7 +11581,10 @@
(dom-append (dom-body) _el-div2) (dom-append (dom-body) _el-div2)
(hs-activate! _el-div1) (hs-activate! _el-div1)
(assert= (dom-get-attr _el-div "data-foo") "bar") (assert= (dom-get-attr _el-div "data-foo") "bar")
(dom-dispatch (nth (dom-query-all (dom-body) "div") 1) "click" nil)
(assert (not (dom-has-attr? _el-div "data-foo"))) (assert (not (dom-has-attr? _el-div "data-foo")))
(assert= (dom-get-attr (nth (dom-query-all (dom-body) "div") 1) "data-foo") "")
(assert (not (dom-has-attr? (nth (dom-query-all (dom-body) "div") 2) "data-foo")))
)) ))
(deftest "can take an attribute value from other elements and set specific values instead" (deftest "can take an attribute value from other elements and set specific values instead"
(hs-cleanup!) (hs-cleanup!)
@@ -11572,7 +11598,10 @@
(dom-append (dom-body) _el-div1) (dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2) (dom-append (dom-body) _el-div2)
(hs-activate! _el-div1) (hs-activate! _el-div1)
(dom-dispatch (nth (dom-query-all (dom-body) "div") 1) "click" nil)
(assert= (dom-get-attr _el-div "data-foo") "qux") (assert= (dom-get-attr _el-div "data-foo") "qux")
(assert= (dom-get-attr (nth (dom-query-all (dom-body) "div") 1) "data-foo") "baz")
(assert= (dom-get-attr (nth (dom-query-all (dom-body) "div") 2) "data-foo") "qux")
)) ))
(deftest "can take an attribute value from other elements and set value from an expression instead" (deftest "can take an attribute value from other elements and set value from an expression instead"
(hs-cleanup!) (hs-cleanup!)
@@ -11587,7 +11616,10 @@
(dom-append (dom-body) _el-div1) (dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2) (dom-append (dom-body) _el-div2)
(hs-activate! _el-div1) (hs-activate! _el-div1)
(dom-dispatch (nth (dom-query-all (dom-body) "div") 1) "click" nil)
(assert= (dom-get-attr _el-div "data-foo") "qux") (assert= (dom-get-attr _el-div "data-foo") "qux")
(assert= (dom-get-attr (nth (dom-query-all (dom-body) "div") 1) "data-foo") "baz")
(assert= (dom-get-attr (nth (dom-query-all (dom-body) "div") 2) "data-foo") "qux")
)) ))
(deftest "can take an attribute with specific value from other elements" (deftest "can take an attribute with specific value from other elements"
(hs-cleanup!) (hs-cleanup!)
@@ -11602,7 +11634,10 @@
(dom-append (dom-body) _el-div2) (dom-append (dom-body) _el-div2)
(hs-activate! _el-div1) (hs-activate! _el-div1)
(assert= (dom-get-attr _el-div "data-foo") "bar") (assert= (dom-get-attr _el-div "data-foo") "bar")
(dom-dispatch (nth (dom-query-all (dom-body) "div") 1) "click" nil)
(assert (not (dom-has-attr? _el-div "data-foo"))) (assert (not (dom-has-attr? _el-div "data-foo")))
(assert= (dom-get-attr (nth (dom-query-all (dom-body) "div") 1) "data-foo") "baz")
(assert (not (dom-has-attr? (nth (dom-query-all (dom-body) "div") 2) "data-foo")))
)) ))
(deftest "can take multiple classes from other elements" (deftest "can take multiple classes from other elements"
(hs-cleanup!) (hs-cleanup!)
@@ -11617,7 +11652,11 @@
(dom-append (dom-body) _el-div1) (dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2) (dom-append (dom-body) _el-div2)
(hs-activate! _el-div1) (hs-activate! _el-div1)
(dom-dispatch (nth (dom-query-all (dom-body) "div") 1) "click" nil)
(assert (not (dom-has-class? _el-div "foo"))) (assert (not (dom-has-class? _el-div "foo")))
(assert (dom-has-class? (nth (dom-query-all (dom-body) "div") 1) "foo"))
(assert (dom-has-class? (nth (dom-query-all (dom-body) "div") 1) "bar"))
(assert (not (dom-has-class? (nth (dom-query-all (dom-body) "div") 2) "bar")))
)) ))
(deftest "can take multiple classes from specific element" (deftest "can take multiple classes from specific element"
(hs-cleanup!) (hs-cleanup!)
@@ -11633,8 +11672,12 @@
(dom-append (dom-body) _el-div1) (dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2) (dom-append (dom-body) _el-div2)
(hs-activate! _el-div1) (hs-activate! _el-div1)
(dom-dispatch (nth (dom-query-all (dom-body) "div") 1) "click" nil)
(assert (not (dom-has-class? _el-div "foo"))) (assert (not (dom-has-class? _el-div "foo")))
(assert (not (dom-has-class? _el-div "bar"))) (assert (not (dom-has-class? _el-div "bar")))
(assert (dom-has-class? (nth (dom-query-all (dom-body) "div") 1) "foo"))
(assert (dom-has-class? (nth (dom-query-all (dom-body) "div") 1) "bar"))
(assert (dom-has-class? (nth (dom-query-all (dom-body) "div") 2) "bar"))
)) ))
(deftest "giving may follow the from clause as an alternative to with" (deftest "giving may follow the from clause as an alternative to with"
(hs-cleanup!) (hs-cleanup!)
@@ -11648,7 +11691,10 @@
(dom-append (dom-body) _el-div1) (dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2) (dom-append (dom-body) _el-div2)
(hs-activate! _el-div1) (hs-activate! _el-div1)
(dom-dispatch (nth (dom-query-all (dom-body) "div") 1) "click" nil)
(assert= (dom-get-attr _el-div "data-foo") "qux") (assert= (dom-get-attr _el-div "data-foo") "qux")
(assert= (dom-get-attr (nth (dom-query-all (dom-body) "div") 1) "data-foo") "baz")
(assert= (dom-get-attr (nth (dom-query-all (dom-body) "div") 2) "data-foo") "qux")
)) ))
) )

View File

@@ -176,17 +176,30 @@ function parseHTMLFragments(html) {
function mt(e,s) { function mt(e,s) {
if(!e||!e.tagName)return false; if(!e||!e.tagName)return false;
s = s.trim(); s = s.trim();
if(s.startsWith('#'))return e.id===s.slice(1); // Strip pseudo-classes we handle at the fnd() level (nth-of-type, first/last-of-type)
if(s.startsWith('.'))return e.classList.contains(s.slice(1)); const pseudo = s.match(/^(.*?):(nth-of-type|first-of-type|last-of-type)(?:\((\d+)\))?$/);
if(s.startsWith('[')) { const base = pseudo ? pseudo[1] : s;
const m = s.match(/^\[([^\]=]+)(?:="([^"]*)")?\]$/); if(base.startsWith('#'))return e.id===base.slice(1);
if(base.startsWith('.'))return e.classList.contains(base.slice(1));
if(base.startsWith('[')) {
const m = base.match(/^\[([^\]=]+)(?:="([^"]*)")?\]$/);
if(m) return m[2] !== undefined ? e.getAttribute(m[1]) === m[2] : e.hasAttribute(m[1]); 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(base.includes('.')) { const [tag, cls] = base.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; } if(base.includes('#')) { const [tag, id] = base.split('#'); return e.tagName.toLowerCase() === tag && e.id === id; }
return e.tagName.toLowerCase() === s.toLowerCase(); return e.tagName.toLowerCase() === base.toLowerCase();
}
function fnd(e,s) {
const pseudo = s && typeof s==='string' ? s.match(/^(.*?):(nth-of-type|first-of-type|last-of-type)(?:\((\d+)\))?$/) : null;
if (pseudo) {
const all = fndAll(e, pseudo[1]);
if (pseudo[2] === 'first-of-type') return all[0] || null;
if (pseudo[2] === 'last-of-type') return all[all.length - 1] || null;
const idx = parseInt(pseudo[3] || '1') - 1;
return all[idx] || null;
}
for(const c of(e.children||[])){if(mt(c,s))return c;const f=fnd(c,s);if(f)return f;} return null;
} }
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));}r.item=function(i){return r[i]||null;};return r; } function fndAll(e,s) { const r=[];for(const c of(e.children||[])){if(mt(c,s))r.push(c);r.push(...fndAll(c,s));}r.item=function(i){return r[i]||null;};return r; }
const _body = new El('body'); const _body = new El('body');

View File

@@ -796,16 +796,19 @@ def parse_dev_body(body, elements, var_names):
def add_action(stmt): def add_action(stmt):
am = re.search( am = re.search(
r"find\((['\"])(.+?)\1\)(?:\.(?:first|last)\(\))?" r"find\((['\"])(.+?)\1\)(?:\.(?:first|last)\(\)|\.nth\((\d+)\))?"
r"\.(click|dispatchEvent|fill|check|uncheck|focus|selectOption)\(([^)]*)\)", r"\.(click|dispatchEvent|fill|check|uncheck|focus|selectOption)\(([^)]*)\)",
stmt, stmt,
) )
if not am or 'expect' in stmt: if not am or 'expect' in stmt:
return False return False
selector = am.group(2) selector = am.group(2)
action_type = am.group(3) nth_idx = am.group(3)
action_arg = am.group(4).strip("'\"") action_type = am.group(4)
action_arg = am.group(5).strip("'\"")
target = selector_to_sx(selector, elements, var_names) target = selector_to_sx(selector, elements, var_names)
if nth_idx is not None:
target = f'(nth (dom-query-all (dom-body) "{selector}") {nth_idx})'
if action_type == 'click': if action_type == 'click':
ops.append(f'(dom-dispatch {target} "click" nil)') ops.append(f'(dom-dispatch {target} "click" nil)')
elif action_type == 'dispatchEvent': elif action_type == 'dispatchEvent':
@@ -830,7 +833,7 @@ def parse_dev_body(body, elements, var_names):
def add_assertion(stmt): def add_assertion(stmt):
em = re.search( em = re.search(
r"expect\(find\((['\"])(.+?)\1\)(?:\.(?:first|last)\(\))?\)\.(not\.)?" r"expect\(find\((['\"])(.+?)\1\)(?:\.(?:first|last)\(\)|\.nth\((\d+)\))?\)\.(not\.)?"
r"(toHaveText|toHaveClass|toHaveCSS|toHaveAttribute|toHaveValue|toBeVisible|toBeHidden|toBeChecked)" r"(toHaveText|toHaveClass|toHaveCSS|toHaveAttribute|toHaveValue|toBeVisible|toBeHidden|toBeChecked)"
r"\(((?:[^()]|\([^()]*\))*)\)", r"\(((?:[^()]|\([^()]*\))*)\)",
stmt, stmt,
@@ -838,10 +841,13 @@ def parse_dev_body(body, elements, var_names):
if not em: if not em:
return False return False
selector = em.group(2) selector = em.group(2)
negated = bool(em.group(3)) nth_idx = em.group(3)
assert_type = em.group(4) negated = bool(em.group(4))
args_str = em.group(5) assert_type = em.group(5)
args_str = em.group(6)
target = selector_to_sx(selector, elements, var_names) target = selector_to_sx(selector, elements, var_names)
if nth_idx is not None:
target = f'(nth (dom-query-all (dom-body) "{selector}") {nth_idx})'
sx = pw_assertion_to_sx(target, negated, assert_type, args_str) sx = pw_assertion_to_sx(target, negated, assert_type, args_str)
if sx: if sx:
ops.append(sx) ops.append(sx)