#!/usr/bin/env python3 """ Generate hs-behavioral.spec.js from upstream _hyperscript test data. Reads spec/tests/hyperscript-upstream-tests.json and produces a data-driven Playwright test file that runs each test in the WASM sandbox. Usage: python3 tests/playwright/generate-hs-tests.py """ import json import re import os 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, 'tests/playwright/hs-behavioral-data.js') with open(INPUT) as f: raw_tests = json.load(f) def normalize_html(html): """Clean up HTML for our harness — ensure IDs exist for element targeting.""" # Remove | separators (upstream convention for multi-element make()) html = html.replace(' | ', '') # If no id in the HTML, add id="el" to first element if ' id=' not in html and ' id =' not in html: html = re.sub(r'^<(\w+)', r'<\1 id="el"', html, count=1) return html def normalize_action(action, html): """Convert upstream action to work with our byId/qs helpers.""" if not action or action == '(see body)': return '' # Replace element variable references with DOM lookups # Common pattern: div.click(), form.click(), d1.click(), etc. # First handle ID-based: d1.click() -> byId("d1").click() action = re.sub(r'\b([a-z]\d+)\.', lambda m: f'byId("{m.group(1)}").', action) # div1.something -> byId("div1") if there's an id, else qs("div") action = re.sub(r'\bdiv1\.', 'byId("div1") && byId("div1").', action) action = re.sub(r'\bdiv2\.', 'byId("div2") && byId("div2").', action) # Generic tag.action: div.click() -> qs("div").click() for tag in ['div', 'form', 'button', 'input', 'span', 'p', 'a', 'section']: action = re.sub(rf'\b{tag}\.', f'qs("{tag}").', action) # Handle document.getElementById patterns action = action.replace('document.getElementById', 'byId') return action def parse_checks(check, html): """Convert Chai-style assertions to {expr, op, expected} tuples. Upstream tests often have pre-action AND post-action assertions joined by &&. Since we run checks only AFTER the action, we keep only the LAST assertion for each expression (which represents the post-action expected state). """ if not check or check == '(no explicit assertion)': return [] all_checks = [] # Split on ' && ' to handle multiple assertions parts = check.split(' && ') for part in parts: part = part.strip() if not part: continue # Pattern: something.should.equal(value) m = re.match(r'(.+?)\.should\.equal\((.+?)\)$', part) if m: expr, expected = m.group(1).strip(), m.group(2).strip() expr = normalize_expr(expr) all_checks.append({'expr': expr, 'op': '==', 'expected': expected}) continue # Pattern: should.equal(null, something) m = re.match(r'should\.equal\(null,\s*(.+?)\)', part) if m: expr = normalize_expr(m.group(1).strip()) all_checks.append({'expr': expr, 'op': '==', 'expected': 'null'}) continue # Pattern: assert.isNull(expr) m = re.match(r'assert\.isNull\((.+?)\)', part) if m: expr = normalize_expr(m.group(1).strip()) all_checks.append({'expr': expr, 'op': '==', 'expected': 'null'}) continue # Pattern: assert.isNotNull(expr) m = re.match(r'assert\.isNotNull\((.+?)\)', part) if m: expr = normalize_expr(m.group(1).strip()) all_checks.append({'expr': expr, 'op': '!=', 'expected': 'null'}) continue # Pattern: something.should.deep.equal(value) m = re.match(r'(.+?)\.should\.deep\.equal\((.+?)\)$', part) if m: expr, expected = m.group(1).strip(), m.group(2).strip() expr = normalize_expr(expr) all_checks.append({'expr': expr, 'op': 'deep==', 'expected': expected}) continue # Deduplicate: keep only the LAST check for each expression # (upstream pattern: first check = pre-action state, last = post-action state) seen = {} for c in all_checks: seen[c['expr']] = c return list(seen.values()) def normalize_expr(expr): """Normalize element references in assertion expressions.""" # ID-based: d1.innerHTML -> byId("d1").innerHTML expr = re.sub(r'\b([a-z]\d+)\.', lambda m: f'byId("{m.group(1)}").', expr) expr = re.sub(r'\bdiv1\.', 'byId("div1").', expr) expr = re.sub(r'\bdiv2\.', 'byId("div2").', expr) expr = re.sub(r'\bdiv3\.', 'byId("div3").', expr) # Bare variable names that are IDs: bar.classList -> byId("bar").classList # Match word.property where word is not a known tag or JS global known_tags = {'div', 'form', 'button', 'input', 'span', 'p', 'a', 'section'} known_globals = {'document', 'window', 'Math', 'JSON', 'console', 'byId', 'qs', 'qsa'} def replace_bare_var(m): name = m.group(1) prop = m.group(2) if name in known_tags or name in known_globals: return m.group(0) return f'byId("{name}").{prop}' expr = re.sub(r'\b([a-z][a-zA-Z]*)\.(classList|innerHTML|textContent|style|parentElement|getAttribute|hasAttribute|children|firstChild|value|dataset|className|outerHTML)', replace_bare_var, expr) # Tag-based: div.classList -> qs("div").classList for tag in known_tags: expr = re.sub(rf'\b{tag}\.', f'qs("{tag}").', expr) # getComputedStyle(div) -> getComputedStyle(qs("div")) for tag in known_tags: expr = expr.replace(f'getComputedStyle({tag})', f'getComputedStyle(qs("{tag}"))') # window.results -> window.results (OK as-is) # Remove any double dots from prior replacements expr = expr.replace('..', '.') return expr # Process tests output_tests = [] skipped = 0 for t in raw_tests: if t.get('complexity') != 'simple': skipped += 1 continue html = normalize_html(t['html']) action = normalize_action(t['action'], html) checks = parse_checks(t['check'], html) # Skip tests with no usable checks (log tests etc) if not checks and not action: skipped += 1 continue # Skip tests with syntax that causes parser hangs hang_patterns = ['[@', '{color', '{font', '{display', '{opacity', '${', 'transition ', 'as ', 'js(', 'make a', 'measure', 'fetch ', '\\\\'] if any(p in html for p in hang_patterns): skipped += 1 continue output_tests.append({ 'category': t['category'], 'name': t['name'], 'html': html, 'action': action, 'checks': checks, 'async': t.get('async', False), }) # Write JS module with open(OUTPUT, 'w') as f: f.write('// Auto-generated from _hyperscript upstream test suite\n') f.write('// Source: spec/tests/hyperscript-upstream-tests.json\n') f.write(f'// {len(output_tests)} tests ({skipped} skipped)\n') f.write('//\n') f.write('// DO NOT EDIT — regenerate with: python3 tests/playwright/generate-hs-tests.py\n\n') f.write('module.exports = ') f.write(json.dumps(output_tests, indent=2)) f.write(';\n') print(f'Generated {len(output_tests)} tests ({skipped} skipped) -> {OUTPUT}') # Category breakdown from collections import Counter cats = Counter(t['category'] for t in output_tests) for cat, n in cats.most_common(): print(f' {cat}: {n}')