Files
rose-ash/tests/playwright/generate-sx-conformance-dev.py
giles 6e27442d57 Step 17: streaming render — hyperscript enhancements, WASM builds, live server tests
Streaming chunked transfer with shell-first suspense and resolve scripts.
Hyperscript parser/compiler/runtime expanded for conformance. WASM static
assets added to OCaml host. Playwright streaming and page-level test suites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 08:41:38 +00:00

377 lines
13 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))
return 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 escape_hs(cmd):
"""Escape a hyperscript command for embedding in SX double-quoted string."""
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, ... }
loc_m = re.search(r'locals:\s*\{([^}]+)\}', ctx_str)
if loc_m:
loc_pairs = []
for kv in re.finditer(r'(\w+):\s*([^,}]+)', loc_m.group(1)):
k = kv.group(1)
v = parse_js_value(kv.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
# ── Test generation ───────────────────────────────────────────────
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('"', "'")
# 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' (error "STUB: needs JS bridge — {t["complexity"]}"))')
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}')