HS test generator: fix toHaveCSS, locals, and \"-escapes — +28 tests

Generator changes (tests/playwright/generate-sx-tests.py):
- toHaveCSS regex: balance parens so `'rgb(255, 0, 0)'` is captured intact
  (was truncating at first `)`)
- Map browser-computed colors `rgb(R,G,B)` back to CSS keywords
  (red/green/blue/black/white) — our DOM mock returns the inline value
- js_val_to_sx now handles object literals `{a: 1, b: {c: 2}}` → `{:a 1 :b {:c 2}}`
- Pattern 2 (`var x = await run(...)`) now captures locals via balanced-brace
  scan and emits `eval-hs-locals` instead of `eval-hs`
- Pattern 1 with locals: emit `eval-hs-locals` (was wrapping in `let`, which
  doesn't reach the inner HS env)
- Stop collapsing `\"` → `"` in raw HTML (line 218): the backslash escapes
  are legitimate in single-quoted `_='...'` HS attribute values containing
  nested HS scripts

Test-framework changes (regenerated into spec/tests/test-hyperscript-behavioral.sx):
- `_hs-wrap-body`: returns expression value if non-nil, else `it`. Lets bare
  expressions (`foo.foo`) and `it`-mutating scripts (`pick first 3 of arr;
  set $test to it`) both round-trip through the same wrapper
- `eval-hs-locals` now injects locals via `(let ((name (quote val)) ...) sx)`
  rather than `apply handler (cons nil vals)` — works around a JIT loop on
  some compiled forms (e.g. `bar.doh of foo` with undefined `bar`)

Also synced lib/hyperscript/*.sx → shared/static/wasm/sx/hs-*.sx (the WASM
test runner reads from the wasm/sx/ copies).

Net per-cluster pass counts (vs prior baseline):
- put: 23 → 29 (+6)
- set: 21 → 28 (+7)
- show: 7 → 15 (+8)
- expressions/propertyAccess: 3 → 9 (+6)
- expressions/possessiveExpression: 17 → 18 (+1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 09:18:21 +00:00
parent 0515295317
commit a11d0941e9
7 changed files with 451 additions and 194 deletions

View File

@@ -214,8 +214,10 @@ def parse_html(html):
# Remove | separators
html = html.replace(' | ', '')
# Fix escaped attribute delimiters from JSON extraction (\" → ")
html = html.replace('\\"', '"')
# Note: previously we collapsed `\"` → `"` here, but that destroys legitimate
# HS string escapes inside single-quoted `_='...'` attributes (e.g. nested
# button HTML in `properly processes hyperscript X` tests). HTMLParser handles
# backslashes in attribute values as literal characters, so we leave them.
elements = []
stack = []
@@ -680,6 +682,17 @@ def pw_assertion_to_sx(target, negated, assert_type, args_str):
elif assert_type == 'toHaveCSS':
prop = args[0] if args else ''
val = args[1] if len(args) >= 2 else ''
# Browsers normalize colors to rgb()/rgba(); our DOM mock returns the
# raw inline value. Map common rgb() forms back to keywords.
rgb_to_name = {
'rgb(255, 0, 0)': 'red',
'rgb(0, 255, 0)': 'green',
'rgb(0, 0, 255)': 'blue',
'rgb(0, 0, 0)': 'black',
'rgb(255, 255, 255)': 'white',
}
if val in rgb_to_name:
val = rgb_to_name[val]
escaped = val.replace('\\', '\\\\').replace('"', '\\"')
if negated:
return f'(assert (!= (dom-get-style {target} "{prop}") "{escaped}"))'
@@ -764,7 +777,7 @@ def parse_dev_body(body, elements, var_names):
m = re.search(
r"expect\(find\((['\"])(.+?)\1\)(?:\.(?:first|last)\(\))?\)\.(not\.)?"
r"(toHaveText|toHaveClass|toHaveCSS|toHaveAttribute|toHaveValue|toBeVisible|toBeHidden|toBeChecked)"
r"\(([^)]*)\)",
r"\(((?:[^()]|\([^()]*\))*)\)",
line
)
if m:
@@ -956,6 +969,24 @@ def js_val_to_sx(val):
return '(list)'
items = [js_val_to_sx(x.strip()) for x in split_top_level(inner)]
return '(list ' + ' '.join(items) + ')'
# Objects: { foo: "bar", baz: 1 } → {:foo "bar" :baz 1}
if val.startswith('{') and val.endswith('}'):
inner = val[1:-1].strip()
if not inner:
return '{}'
parts = []
for kv in split_top_level(inner):
kv = kv.strip()
if not kv:
continue
# key: value (key is identifier or quoted string)
m = re.match(r'^(?:"([^"]+)"|\'([^\']+)\'|(\w+))\s*:\s*(.+)$', kv, re.DOTALL)
if not m:
return f'"{val}"'
key = m.group(1) or m.group(2) or m.group(3)
v = js_val_to_sx(m.group(4))
parts.append(f':{key} {v}')
return '{' + ' '.join(parts) + '}'
try:
float(val)
return val
@@ -1044,12 +1075,13 @@ def generate_eval_only_test(test, idx):
me_match = re.search(r'\bme:\s*(\d+)', opts_str)
locals_match = re.search(r'locals:\s*\{([^}]+)\}', opts_str)
if locals_match:
local_bindings = []
local_pairs = []
for lm in re.finditer(r'(\w+)\s*:\s*([^,}]+)', locals_match.group(1)):
lname = lm.group(1)
lval = js_val_to_sx(lm.group(2).strip())
local_bindings.append(f'({lname} {lval})')
assertions.append(f' (let ({" ".join(local_bindings)}) (assert= (eval-hs "{hs_expr}") {expected_sx}))')
local_pairs.append((lname, lval))
locals_sx = '(list ' + ' '.join(f'(list (quote {n}) {v})' for n, v in local_pairs) + ')' if local_pairs else '(list)'
assertions.append(f' (assert= (eval-hs-locals "{hs_expr}" {locals_sx}) {expected_sx})')
elif me_match:
me_val = me_match.group(1)
assertions.append(f' (assert= (eval-hs-with-me "{hs_expr}" {me_val}) {expected_sx})')
@@ -1088,6 +1120,43 @@ def generate_eval_only_test(test, idx):
if run_match:
hs_expr = extract_hs_expr(run_match.group(2))
var_name = re.search(r'(?:var|let|const)\s+(\w+)', body).group(1)
# Capture locals from the run() call, if present. Use balanced-brace
# extraction so nested {a: {b: 1}} doesn't truncate at the inner }.
local_pairs = []
locals_idx = body.find('locals:')
if locals_idx >= 0:
# Find the opening { after "locals:"
open_idx = body.find('{', locals_idx)
if open_idx >= 0:
depth = 0
end_idx = -1
in_str = None
for i in range(open_idx, len(body)):
ch = body[i]
if in_str:
if ch == in_str and body[i-1] != '\\':
in_str = None
continue
if ch in ('"', "'", '`'):
in_str = ch
continue
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
end_idx = i
break
if end_idx > open_idx:
locals_str = body[open_idx + 1:end_idx].strip()
for kv in split_top_level(locals_str):
kv = kv.strip()
m = re.match(r'^(\w+)\s*:\s*(.+)$', kv, re.DOTALL)
if m:
local_pairs.append((m.group(1), js_val_to_sx(m.group(2).strip())))
locals_sx = '(list ' + ' '.join(f'(list (quote {n}) {v})' for n, v in local_pairs) + ')' if local_pairs else None
def eval_call(expr):
return f'(eval-hs-locals "{expr}" {locals_sx})' if locals_sx else f'(eval-hs "{expr}")'
for m in re.finditer(r'expect\((' + re.escape(var_name) + r'(?:\["[^"]+"\]|\.\w+)?)\)\.toBe\(([^)]+)\)', body):
accessor = m.group(1)
expected_sx = js_val_to_sx(m.group(2))
@@ -1095,17 +1164,17 @@ def generate_eval_only_test(test, idx):
prop_m = re.search(r'\["([^"]+)"\]|\.(\w+)', accessor[len(var_name):])
if prop_m:
prop = prop_m.group(1) or prop_m.group(2)
assertions.append(f' (assert= (host-get (eval-hs "{hs_expr}") "{prop}") {expected_sx})')
assertions.append(f' (assert= (host-get {eval_call(hs_expr)} "{prop}") {expected_sx})')
else:
assertions.append(f' (assert= (eval-hs "{hs_expr}") {expected_sx})')
assertions.append(f' (assert= {eval_call(hs_expr)} {expected_sx})')
for m in re.finditer(r'expect\(' + re.escape(var_name) + r'(?:\.\w+)?\)\.toEqual\((\[.*?\])\)', body, re.DOTALL):
expected_sx = js_val_to_sx(m.group(1))
assertions.append(f' (assert= (eval-hs "{hs_expr}") {expected_sx})')
assertions.append(f' (assert= {eval_call(hs_expr)} {expected_sx})')
# Handle .map(x => x.prop) before toEqual
for m in re.finditer(r'expect\(' + re.escape(var_name) + r'\.map\(\w+\s*=>\s*\w+\.(\w+)\)\)\.toEqual\((\[.*?\])\)', body, re.DOTALL):
prop = m.group(1)
expected_sx = js_val_to_sx(m.group(2))
assertions.append(f' (assert= (map (fn (x) (get x "{prop}")) (eval-hs "{hs_expr}")) {expected_sx})')
assertions.append(f' (assert= (map (fn (x) (get x "{prop}")) {eval_call(hs_expr)}) {expected_sx})')
# Pattern 2b: run() with locals + evaluate(window.X) + expect().toBe/toEqual
# e.g.: await run(`expr`, {locals: {arr: [1,2,3]}});
@@ -1480,13 +1549,24 @@ output.append('(define hs-cleanup!')
output.append(' (fn ()')
output.append(' (dom-set-inner-html (dom-body) "")))')
output.append('')
output.append(';; Evaluate a hyperscript expression and return the last-expression value.')
output.append(';; Compiles the expression, wraps in a thunk, evaluates, returns result.')
output.append(';; Evaluate a hyperscript expression and return either the expression')
output.append(';; value or `it` (whichever is non-nil). Multi-statement scripts that')
output.append(';; mutate `it` (e.g. `pick first 3 of arr; set $test to it`) get `it` back;')
output.append(';; bare expressions (e.g. `foo.foo`) get the expression value back.')
output.append('(define _hs-wrap-body')
output.append(' (fn (sx)')
output.append(' (list (quote let)')
output.append(' (list (list (quote it) nil) (list (quote event) nil))')
output.append(' (list (quote let)')
output.append(' (list (list (quote _ret) sx))')
output.append(' (list (quote if) (list (quote nil?) (quote _ret)) (quote it) (quote _ret))))))')
output.append('')
output.append('(define eval-hs')
output.append(' (fn (src)')
output.append(' (let ((sx (hs-to-sx (hs-compile src))))')
output.append(' (let ((handler (eval-expr-cek')
output.append(' (list (quote fn) (list (quote me)) (list (quote let) (list (list (quote it) nil) (list (quote event) nil)) sx)))))')
output.append(' (list (quote fn) (list (quote me))')
output.append(' (list (quote let) (list (list (quote it) nil) (list (quote event) nil)) sx)))))')
output.append(' (guard')
output.append(' (_e')
output.append(' (true')
@@ -1497,18 +1577,16 @@ output.append(' (raise _e))))')
output.append(' (handler nil))))))')
output.append('')
output.append(';; Evaluate a hyperscript expression with locals. bindings = list of (symbol value).')
output.append(';; The locals are injected as fn params so they resolve in the handler body.')
output.append(';; Locals are injected as a `let` wrapping the compiled body, then evaluated')
output.append(';; in a fresh CEK env. Avoids `apply` (whose JIT path can loop on some forms).')
output.append('(define eval-hs-locals')
output.append(' (fn (src bindings)')
output.append(' (let ((sx (hs-to-sx (hs-compile src))))')
output.append(' (let ((names (map (fn (b) (first b)) bindings))')
output.append(' (vals (map (fn (b) (nth b 1)) bindings)))')
output.append(' (let ((param-list (cons (quote me) names)))')
output.append(' (let ((wrapper (list (quote fn) param-list')
output.append(' (list (quote let)')
output.append(' (list (list (quote it) nil) (list (quote event) nil))')
output.append(' sx (quote it)))))')
output.append(' (let ((handler (eval-expr-cek wrapper)))')
output.append(' ;; Build (let ((name1 (quote val1)) ...) <wrap-body>)')
output.append(' (let ((let-binds (map (fn (b) (list (first b) (list (quote quote) (nth b 1)))) bindings)))')
output.append(' (let ((wrapped (list (quote let) let-binds (_hs-wrap-body sx))))')
output.append(' (let ((thunk (list (quote fn) (list (quote me)) wrapped)))')
output.append(' (let ((handler (eval-expr-cek thunk)))')
output.append(' (guard')
output.append(' (_e')
output.append(' (true')
@@ -1516,14 +1594,14 @@ output.append(' (if')
output.append(' (and (list? _e) (= (first _e) "hs-return"))')
output.append(' (nth _e 1)')
output.append(' (raise _e))))')
output.append(' (apply handler (cons nil vals))))))))))')
output.append(' (handler nil)))))))))')
output.append('')
output.append(';; Evaluate with a specific me value (for "I am between" etc.)')
output.append('(define eval-hs-with-me')
output.append(' (fn (src me-val)')
output.append(' (let ((sx (hs-to-sx (hs-compile src))))')
output.append(' (let ((handler (eval-expr-cek')
output.append(' (list (quote fn) (list (quote me)) (list (quote let) (list (list (quote it) nil) (list (quote event) nil)) sx)))))')
output.append(' (list (quote fn) (list (quote me)) (_hs-wrap-body sx)))))')
output.append(' (guard')
output.append(' (_e')
output.append(' (true')