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))
(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
hs-sorted-by

View File

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

View File

@@ -1803,9 +1803,35 @@
(fn (p) (nth p 1))
(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
hs-sorted-by

View File

@@ -1309,7 +1309,10 @@
(dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2)
(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 (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"
(hs-cleanup!)
@@ -10816,6 +10819,7 @@
(hs-activate! _el-div)
(dom-dispatch (dom-query ".divs") "click" nil)
(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"
(hs-cleanup!)
@@ -11035,6 +11039,7 @@
(hs-activate! _el-trigger)
(dom-dispatch (dom-query-by-id "trigger") "click" nil)
(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"
(hs-cleanup!)
@@ -11467,8 +11472,12 @@
(dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2)
(hs-activate! _el-div1)
(dom-dispatch (nth (dom-query-all (dom-body) "div") 1) "click" nil)
(assert (dom-has-class? _el-div "unselected"))
(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"
(hs-cleanup!)
@@ -11482,8 +11491,12 @@
(dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2)
(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 (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"
(hs-cleanup!)
@@ -11498,7 +11511,9 @@
(dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-d3)
(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? (nth (dom-query-all (dom-body) "div") 1) "foo")))
(assert (dom-has-class? (dom-query-by-id "d3") "foo"))
))
(deftest "can take a class from other elements"
@@ -11513,7 +11528,10 @@
(dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2)
(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 (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"
(hs-cleanup!)
@@ -11527,7 +11545,10 @@
(dom-append (dom-body) _el-form1)
(dom-append (dom-body) _el-form2)
(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 (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"
(hs-cleanup!)
@@ -11542,7 +11563,9 @@
(dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-d3)
(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? (nth (dom-query-all (dom-body) "div") 1) "data-foo")))
(assert= (dom-get-attr (dom-query-by-id "d3") "data-foo") "")
))
(deftest "can take an attribute from other elements"
@@ -11558,7 +11581,10 @@
(dom-append (dom-body) _el-div2)
(hs-activate! _el-div1)
(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= (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"
(hs-cleanup!)
@@ -11572,7 +11598,10 @@
(dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2)
(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 (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"
(hs-cleanup!)
@@ -11587,7 +11616,10 @@
(dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2)
(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 (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"
(hs-cleanup!)
@@ -11602,7 +11634,10 @@
(dom-append (dom-body) _el-div2)
(hs-activate! _el-div1)
(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= (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"
(hs-cleanup!)
@@ -11617,7 +11652,11 @@
(dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2)
(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 (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"
(hs-cleanup!)
@@ -11633,8 +11672,12 @@
(dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2)
(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 "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"
(hs-cleanup!)
@@ -11648,7 +11691,10 @@
(dom-append (dom-body) _el-div1)
(dom-append (dom-body) _el-div2)
(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 (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) {
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(/^\[([^\]=]+)(?:="([^"]*)")?\]$/);
// Strip pseudo-classes we handle at the fnd() level (nth-of-type, first/last-of-type)
const pseudo = s.match(/^(.*?):(nth-of-type|first-of-type|last-of-type)(?:\((\d+)\))?$/);
const base = pseudo ? pseudo[1] : s;
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(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();
if(base.includes('.')) { const [tag, cls] = base.split('.'); return e.tagName.toLowerCase() === tag && e.classList.contains(cls); }
if(base.includes('#')) { const [tag, id] = base.split('#'); return e.tagName.toLowerCase() === tag && e.id === id; }
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; }
const _body = new El('body');

View File

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