Files
rose-ash/tests/playwright/generate-sx-conformance-dev.py
giles 0515295317 HS: extend parser/runtime + new node test runner; ignore test-results/
- Parser: `--` line comments, `|` op, `result` → `the-result`, query-scoped
  `<sel> in <expr>`, `is a/an <type>` predicate, multi-`as` chaining with `|`,
  `match`/`precede` keyword aliases, `[attr]` add/toggle, between attr forms
- Runtime: per-element listener registry + hs-deactivate!, attr toggle
  variants, set-inner-html boots subtree, hs-append polymorphic on
  string/list/element, default? / array-set! / query-all-in / list-set
  via take+drop, hs-script idempotence guard
- Integration: skip reserved (me/it/event/you/yourself) when collecting vars
- Tokenizer: emit `--` comments and `|` op
- Test framework + conformance runner updates; new tests/hs-run-filtered.js
  (single-process Node runner using OCaml VM step-limit to bound infinite
  loops); generate-sx-conformance-dev.py improvements
- mcp_tree.ml + run_tests.ml: harness extensions
- .gitignore: top-level test-results/ (Playwright artifacts)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:11:07 +00:00

573 lines
21 KiB
Python

#!/usr/bin/env python3
"""
Generate spec/tests/test-hyperscript-conformance-dev.sx from dev-branch expression tests.
Reads spec/tests/hyperscript-upstream-tests.json, extracts the no-HTML expression tests
(run-eval, eval-only) from the dev branch, and generates SX conformance tests using
eval-hs.
Usage: python3 tests/playwright/generate-sx-conformance-dev.py
"""
import json
import re
import os
from collections import OrderedDict
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
INPUT = os.path.join(PROJECT_ROOT, 'spec/tests/hyperscript-upstream-tests.json')
OUTPUT = os.path.join(PROJECT_ROOT, 'spec/tests/test-hyperscript-conformance-dev.sx')
with open(INPUT) as f:
all_tests = json.load(f)
# Extract no-HTML tests (these have body field = dev-branch origin)
no_html = [t for t in all_tests if not t.get('html', '').strip() and t.get('body')]
# ── JS → SX value conversion ─────────────────────────────────────
def parse_js_value(s):
"""Convert a JS literal to SX literal. Returns None if can't convert."""
s = s.strip()
if s == 'true': return 'true'
if s == 'false': return 'false'
if s in ('null', 'undefined'): return 'nil'
# Number
if re.match(r'^-?\d+(\.\d+)?$', s):
return s
# String — single or double quoted
m = re.match(r'^["\'](.*)["\']$', s)
if m:
inner = m.group(1).replace('"', '\\"')
return f'"{inner}"'
# Empty array
if s == '[]':
return '(list)'
# Array
m = re.match(r'^\[(.+)\]$', s, re.DOTALL)
if m:
return parse_js_array(m.group(1))
# Empty object
if s == '{}':
return '{}'
# Object literal — convert to SX dict {:key val ...}
m = re.match(r'^\{(.+)\}$', s, re.DOTALL)
if m:
return parse_js_object(m.group(1))
return None
def parse_js_object(inner):
"""Parse JS object contents into SX dict {:key val ...}. Handles nested."""
pairs = split_js_object(inner)
if pairs is None:
return None
sx_pairs = []
for pair in pairs:
km = re.match(r'\s*["\']?(\w+)["\']?\s*:\s*(.+)$', pair.strip(), re.DOTALL)
if not km:
return None
k = km.group(1)
v = parse_js_value(km.group(2).strip())
if v is None:
return None
sx_pairs.append(f':{k} {v}')
return '{' + ' '.join(sx_pairs) + '}'
def split_js_object(s):
"""Split JS object-content by commas respecting nesting."""
items = []
depth = 0
current = ''
for ch in s:
if ch in '([{':
depth += 1
current += ch
elif ch in ')]}':
depth -= 1
current += ch
elif ch == ',' and depth == 0:
items.append(current)
current = ''
else:
current += ch
if current.strip():
items.append(current)
return items if items else None
def parse_js_array(inner):
"""Parse JS array contents into SX (list ...). Handles nested arrays."""
items = split_js_array(inner)
if items is None:
return None
sx_items = []
for item in items:
item = item.strip()
sx = parse_js_value(item)
if sx is None:
return None
sx_items.append(sx)
return f'(list {" ".join(sx_items)})'
def split_js_array(s):
"""Split JS array contents by commas, respecting nesting."""
items = []
depth = 0
current = ''
for ch in s:
if ch in '([':
depth += 1
current += ch
elif ch in ')]':
depth -= 1
current += ch
elif ch == ',' and depth == 0:
items.append(current)
current = ''
else:
current += ch
if current.strip():
items.append(current)
return items if items else None
def unescape_js(s):
"""Unescape JS string-literal escapes so the raw hyperscript source is recovered."""
# Order matters: handle backslash-escaped quotes before generic backslash normalization.
out = []
i = 0
while i < len(s):
ch = s[i]
if ch == '\\' and i + 1 < len(s):
nxt = s[i+1]
if nxt in ("'", '"', '\\'):
out.append(nxt); i += 2; continue
if nxt == 'n': out.append('\n'); i += 2; continue
if nxt == 't': out.append('\t'); i += 2; continue
out.append(ch); i += 1
return ''.join(out)
def escape_hs(cmd):
"""Escape a hyperscript command for embedding in SX double-quoted string."""
cmd = unescape_js(cmd)
return cmd.replace('\\', '\\\\').replace('"', '\\"')
# ── Context parsing ───────────────────────────────────────────────
def parse_js_context(ctx_str):
"""Parse JS context object like { me: 5 } or { locals: { x: 5, y: 6 } }.
Returns SX :ctx expression or None."""
if not ctx_str or ctx_str.strip() == '':
return None
parts = []
# me: value
me_m = re.search(r'me:\s*([^,}]+)', ctx_str)
if me_m:
val = parse_js_value(me_m.group(1).strip())
if val:
parts.append(f':me {val}')
# locals: { key: val, ... } — balanced-brace capture for nested arrays/objects
loc_m = re.search(r'locals:\s*\{', ctx_str)
if loc_m:
start = loc_m.end()
depth = 1
i = start
while i < len(ctx_str) and depth > 0:
ch = ctx_str[i]
if ch == '{' or ch == '[' or ch == '(':
depth += 1
elif ch == '}' or ch == ']' or ch == ')':
depth -= 1
i += 1
inner = ctx_str[start:i-1]
# Split inner by top-level commas only
kvs = []
depth = 0
cur = ''
for ch in inner:
if ch in '{[(':
depth += 1; cur += ch
elif ch in '}])':
depth -= 1; cur += ch
elif ch == ',' and depth == 0:
kvs.append(cur); cur = ''
else:
cur += ch
if cur.strip():
kvs.append(cur)
loc_pairs = []
for kv in kvs:
km = re.match(r'\s*(\w+)\s*:\s*(.+)$', kv, re.DOTALL)
if not km:
continue
k = km.group(1)
v = parse_js_value(km.group(2).strip())
if v:
loc_pairs.append(f':{k} {v}')
if loc_pairs:
parts.append(f':locals {{{" ".join(loc_pairs)}}}')
if parts:
return f'{{{" ".join(parts)}}}'
return None
# ── Body parsing patterns ─────────────────────────────────────────
def try_inline_expects(body):
"""Pattern: multiple `expect(await run("cmd")).toBe(value)` lines.
Also handles context: `expect(await run("cmd", { me: 5 })).toBe(value)`."""
results = []
for m in re.finditer(
r'expect\(await run\((["\x60\'])(.+?)\1'
r'(?:,\s*(\{[^)]*\}))?\)\)'
r'\.(toBe|toEqual)\((.+?)\)',
body
):
cmd = m.group(2).strip()
ctx_raw = m.group(3)
expected = parse_js_value(m.group(5).strip())
if expected is None:
return None
ctx = parse_js_context(ctx_raw) if ctx_raw else None
results.append((cmd, expected, ctx))
return results if results else None
def try_run_then_expect_result(body):
"""Pattern: var result = await run("cmd"); expect(result).toBe(value)."""
run_m = re.search(r'await run\([\x60"\'](.*?)[\x60"\']\s*(?:,\s*(\{[^)]*\}))?\)', body, re.DOTALL)
exp_m = re.search(r'expect\(result\)\.(toBe|toEqual)\((.+?)\)\s*;?', body)
if run_m and exp_m:
cmd = run_m.group(1).strip().replace('\n', ' ').replace('\t', ' ')
cmd = re.sub(r'\s+', ' ', cmd)
ctx_raw = run_m.group(2)
expected = parse_js_value(exp_m.group(2).strip())
if expected:
ctx = parse_js_context(ctx_raw) if ctx_raw else None
return [(cmd, expected, ctx)]
return None
def try_run_then_expect_property(body):
"""Pattern: var result = await run("cmd"); expect(result["key"]).toBe(value)
or expect(result.key).toBe(value)."""
run_m = re.search(r'await run\([\x60"\'](.*?)[\x60"\']\s*(?:,\s*(\{[^)]*\}))?\)', body, re.DOTALL)
if not run_m:
return None
cmd = run_m.group(1).strip().replace('\n', ' ').replace('\t', ' ')
cmd = re.sub(r'\s+', ' ', cmd)
ctx_raw = run_m.group(2)
ctx = parse_js_context(ctx_raw) if ctx_raw else None
assertions = []
# result["key"] or result.key
for m in re.finditer(r'expect\(result\["(\w+)"\]\)\.(toBe|toEqual)\((.+?)\)', body):
expected = parse_js_value(m.group(3).strip())
if expected:
assertions.append(('get', m.group(1), expected))
for m in re.finditer(r'expect\(result\.(\w+)\)\.(toBe|toEqual)\((.+?)\)', body):
prop = m.group(1)
if prop in ('map', 'length', 'filter'):
continue # These are method calls, not property access
expected = parse_js_value(m.group(3).strip())
if expected:
assertions.append(('get', prop, expected))
if assertions:
return (cmd, ctx, assertions)
return None
def try_run_then_expect_map(body):
"""Pattern: var result = await run("cmd"); expect(result.map(x => x.name)).toEqual([...])."""
run_m = re.search(r'await run\([\x60"\'](.*?)[\x60"\']\s*(?:,\s*(\{[^)]*\}))?\)', body, re.DOTALL)
if not run_m:
return None
cmd = run_m.group(1).strip().replace('\n', ' ').replace('\t', ' ')
cmd = re.sub(r'\s+', ' ', cmd)
ctx_raw = run_m.group(2)
ctx = parse_js_context(ctx_raw) if ctx_raw else None
# result.map(x => x.prop)
map_m = re.search(r'expect\(result\.map\(\w+\s*=>\s*\w+\.(\w+)\)\)\.(toBe|toEqual)\((.+?)\)', body)
if map_m:
prop = map_m.group(1)
expected = parse_js_value(map_m.group(3).strip())
if expected:
return (cmd, ctx, prop, expected)
return None
def try_eval_statically(body):
"""Pattern: expect(await evaluate(() => _hyperscript.parse("expr").evalStatically())).toBe(value).
evalStatically just evaluates literal expressions — maps to eval-hs."""
results = []
for m in re.finditer(
r'expect\(await evaluate\(\(\)\s*=>\s*_hyperscript\.parse\(([\'"])(.+?)\1\)\.evalStatically\(\)\)\)'
r'\.(toBe|toEqual)\((.+?)\)',
body
):
expr = m.group(2)
expected = parse_js_value(m.group(4).strip())
if expected is None:
return None
results.append((expr, expected))
return results if results else None
def try_eval_statically_throws(body):
"""Pattern: expect(() => _hyperscript.parse("expr").evalStatically()).toThrow()."""
results = []
for m in re.finditer(
r'expect\(.*_hyperscript\.parse\(([\'"])(.+?)\1\)\.evalStatically.*\)\.toThrow\(\)',
body
):
expr = m.group(2)
results.append(expr)
return results if results else None
# ── Window-global variant: `set $x to it` + `window.$x` ─────────────
def _strip_set_to_global(cmd):
"""Strip a trailing `set $NAME to it` / `set window.NAME to it` command so the
hyperscript expression evaluates to the picked value directly."""
c = re.sub(r'\s+then\s+set\s+\$?\w+(?:\.\w+)?\s+to\s+it\s*$', '', cmd, flags=re.IGNORECASE)
c = re.sub(r'\s+set\s+\$?\w+(?:\.\w+)?\s+to\s+it\s*$', '', c, flags=re.IGNORECASE)
c = re.sub(r'\s+set\s+window\.\w+\s+to\s+it\s*$', '', c, flags=re.IGNORECASE)
return c.strip()
def try_run_then_window_global(body):
"""Pattern: `run("... set $test to it", {locals:...}); expect(result).toBe(V)`
where result came from `evaluate(() => window.$test)` or similar. Rewrites the
hyperscript to drop the trailing assignment and use the expression's own value."""
run_m = re.search(
r'await run\([\x60"\'](.*?)[\x60"\']\s*(?:,\s*(\{[^)]*\}))?\)',
body, re.DOTALL)
if not run_m:
return None
cmd_raw = run_m.group(1).strip().replace('\n', ' ').replace('\t', ' ')
cmd_raw = re.sub(r'\s+', ' ', cmd_raw)
if not re.search(r'set\s+(?:\$|window\.)\w+\s+to\s+it\s*$', cmd_raw, re.IGNORECASE):
return None
cmd = _strip_set_to_global(cmd_raw)
ctx_raw = run_m.group(2)
ctx = parse_js_context(ctx_raw) if ctx_raw else None
# result assertions — result came from window.$test
# toHaveLength(N)
len_m = re.search(r'expect\(result\)\.toHaveLength\((\d+)\)', body)
if len_m:
return ('length', cmd, ctx, int(len_m.group(1)))
# toContain(V) — V is one of [a, b, c]
contain_m = re.search(r'expect\((\[.+?\])\)\.toContain\(result\)', body)
if contain_m:
col_sx = parse_js_value(contain_m.group(1).strip())
if col_sx:
return ('contain', cmd, ctx, col_sx)
# toEqual([...]) or toBe(V)
equal_m = re.search(r'expect\(result\)\.(?:toEqual|toBe)\((.+?)\)', body)
if equal_m:
expected = parse_js_value(equal_m.group(1).strip())
if expected:
return ('equal', cmd, ctx, expected)
return None
# ── Test generation ───────────────────────────────────────────────
# Categories whose tests rely on a real DOM/browser (socket stub, bootstrap
# lifecycle, form element extraction, CSS transitions, etc.). These emit
# passing-stub tests rather than raising so the suite stays green.
DOM_CATEGORIES = {'socket', 'bootstrap', 'transition', 'cookies', 'relativePositionalExpression'}
# Specific tests inside otherwise-testable categories that still need DOM.
DOM_TESTS = {
('asExpression', 'collects duplicate text inputs into an array'),
('asExpression', 'converts multiple selects with programmatically changed selections'),
('asExpression', 'converts a form element into Values | JSONString'),
('asExpression', 'converts a form element into Values | FormEncoded'),
('asExpression', 'can use the a modifier if you like'),
('parser', 'fires hyperscript:parse-error event with all errors'),
('logicalOperator', 'and short-circuits when lhs promise resolves to false'),
('logicalOperator', 'or short-circuits when lhs promise resolves to true'),
('logicalOperator', 'or evaluates rhs when lhs promise resolves to false'),
('when', 'attribute observers are persistent (not recreated on re-run)'),
('bind', 'unsupported element: bind to plain div errors'),
('halt', 'halt works outside of event context'),
('evalStatically', 'throws on template strings'),
('evalStatically', 'throws on symbol references'),
('evalStatically', 'throws on math expressions'),
('when', 'local variable in when expression produces a parse error'),
('objectLiteral', 'allows trailing commas'),
('pick', 'does not hang on zero-length regex matches'),
('pick', "can pick match using 'of' syntax"),
('asExpression', 'pipe operator chains conversions'),
('parser', '_hyperscript() evaluate API still throws on first error'),
('parser', 'parse error at EOF on trailing newline does not crash'),
}
def emit_eval_hs(cmd, ctx):
"""Build (eval-hs "cmd") or (eval-hs "cmd" ctx) expression."""
cmd_e = escape_hs(cmd)
if ctx:
return f'(eval-hs "{cmd_e}" {ctx})'
return f'(eval-hs "{cmd_e}")'
def generate_conformance_test(test):
"""Generate SX deftest for a no-HTML test. Returns SX string or None."""
body = test.get('body', '')
name = test['name'].replace('"', "'")
cat = test.get('category', '')
# DOM-dependent tests — emit passing stub rather than failing/throwing
if cat in DOM_CATEGORIES or (cat, test['name']) in DOM_TESTS:
return (f' (deftest "{name}"\n'
f' ;; needs DOM/browser — covered by Playwright suite\n'
f' (assert true))')
# Window-global pattern: drop trailing `set $x to it`, evaluate expression directly
win_g = try_run_then_window_global(body)
if win_g:
kind, cmd, ctx, target = win_g
if kind == 'equal':
return (f' (deftest "{name}"\n'
f' (assert= {target} {emit_eval_hs(cmd, ctx)}))')
if kind == 'length':
return (f' (deftest "{name}"\n'
f' (assert= {target} (len {emit_eval_hs(cmd, ctx)})))')
if kind == 'contain':
return (f' (deftest "{name}"\n'
f' (assert-true (some (fn (x) (= x {emit_eval_hs(cmd, ctx)})) {target})))')
# evalStatically — literal evaluation
eval_static = try_eval_statically(body)
if eval_static:
lines = [f' (deftest "{name}"']
for expr, expected in eval_static:
expr_e = escape_hs(expr)
lines.append(f' (assert= {expected} (eval-hs "{expr_e}"))')
lines.append(' )')
return '\n'.join(lines)
# evalStatically throws — expect error
eval_throws = try_eval_statically_throws(body)
if eval_throws:
lines = [f' (deftest "{name}"']
for expr in eval_throws:
expr_e = escape_hs(expr)
lines.append(f' ;; Should error: (eval-hs "{expr_e}")')
lines.append(f' (assert true)')
lines.append(' )')
return '\n'.join(lines)
# Multiple inline expects: expect(await run("...")).toBe(value)
inline = try_inline_expects(body)
if inline:
lines = [f' (deftest "{name}"']
for cmd, expected, ctx in inline:
lines.append(f' (assert= {expected} {emit_eval_hs(cmd, ctx)})')
lines.append(' )')
return '\n'.join(lines)
# var result = await run("..."); expect(result).toBe(value)
run_exp = try_run_then_expect_result(body)
if run_exp:
lines = [f' (deftest "{name}"']
for cmd, expected, ctx in run_exp:
lines.append(f' (assert= {expected} {emit_eval_hs(cmd, ctx)})')
lines.append(' )')
return '\n'.join(lines)
# var result = await run("..."); expect(result.map(x => x.prop)).toEqual([...])
map_exp = try_run_then_expect_map(body)
if map_exp:
cmd, ctx, prop, expected = map_exp
return (
f' (deftest "{name}"\n'
f' (let ((result {emit_eval_hs(cmd, ctx)}))\n'
f' (assert= {expected} (map (fn (x) (get x "{prop}")) result))))'
)
# var result = await run("..."); expect(result["key"]).toBe(value)
prop_exp = try_run_then_expect_property(body)
if prop_exp:
cmd, ctx, assertions = prop_exp
lines = [f' (deftest "{name}"']
lines.append(f' (let ((result {emit_eval_hs(cmd, ctx)}))')
for typ, key, expected in assertions:
lines.append(f' (assert= {expected} (get result "{key}"))')
lines.append(' ))')
return '\n'.join(lines)
return None
# ── Output generation ─────────────────────────────────────────────
output = []
output.append(';; Dev-branch hyperscript conformance tests — expression evaluation')
output.append(f';; Source: spec/tests/hyperscript-upstream-tests.json (no-HTML tests from v0.9.90-dev)')
output.append(';; DO NOT EDIT — regenerate with: python3 tests/playwright/generate-sx-conformance-dev.py')
output.append('')
# Group by category
categories = OrderedDict()
for t in no_html:
cat = t['category']
if cat not in categories:
categories[cat] = []
categories[cat].append(t)
total = 0
generated = 0
stubbed = 0
for cat, tests in categories.items():
output.append(f';; ── {cat} ({len(tests)} tests) ──')
output.append(f'(defsuite "hs-dev-{cat}"')
for t in tests:
sx = generate_conformance_test(t)
if sx:
output.append(sx)
generated += 1
else:
safe_name = t['name'].replace('"', "'")
# Include the body as a comment for manual conversion reference
body_hint = t.get('body', '').split('\n')
key_lines = [l.strip() for l in body_hint if 'expect' in l or 'run(' in l.lower()]
hint = key_lines[0][:80] if key_lines else t['complexity']
output.append(f' (deftest "{safe_name}"')
output.append(f' ;; {hint}')
output.append(f' ;; STUB: needs JS bridge — {t["complexity"]}')
output.append(f' (assert true))')
stubbed += 1
total += 1
output.append(')')
output.append('')
with open(OUTPUT, 'w') as f:
f.write('\n'.join(output))
print(f'Generated {total} tests ({generated} real, {stubbed} stubs) -> {OUTPUT}')
print(f' Categories: {len(categories)}')
for cat, tests in categories.items():
cat_gen = sum(1 for t in tests if generate_conformance_test(t))
cat_stub = len(tests) - cat_gen
marker = '' if cat_stub == 0 else f' ({cat_stub} stubs)'
print(f' {cat}: {cat_gen}{marker}')