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