#!/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}')