#!/usr/bin/env python3 """ Generate spec/tests/test-hyperscript-behavioral.sx from upstream _hyperscript test data. Reads spec/tests/hyperscript-upstream-tests.json and produces SX deftest forms that run in the Playwright sandbox with real DOM. Usage: python3 tests/playwright/generate-sx-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, 'spec/tests/test-hyperscript-behavioral.sx') with open(INPUT) as f: raw_tests = json.load(f) def parse_html(html): """Parse HTML into list of element dicts. Uses Python's html.parser for reliability with same-tag siblings.""" from html.parser import HTMLParser # Remove | separators html = html.replace(' | ', '') elements = [] stack = [] class Parser(HTMLParser): def handle_starttag(self, tag, attrs): el = { 'tag': tag, 'id': None, 'classes': [], 'hs': None, 'attrs': {}, 'inner': '', 'depth': len(stack) } for name, val in attrs: if name == 'id': el['id'] = val elif name == 'class': el['classes'] = (val or '').split() elif name == '_': el['hs'] = val elif name == 'style': el['attrs']['style'] = val or '' elif val is not None: el['attrs'][name] = val stack.append(el) # Only collect top-level elements if el['depth'] == 0: elements.append(el) def handle_endtag(self, tag): if stack and stack[-1]['tag'] == tag: stack.pop() def handle_data(self, data): pass Parser().feed(html) return elements def parse_action(action): """Convert upstream action to SX. Returns list of SX expressions.""" if not action or action == '(see body)': return [] exprs = [] # Split on ';' for multi-step actions for part in action.split(';'): part = part.strip() if not part: continue # Pattern: var.click() m = re.match(r'(\w+)\.click\(\)', part) if m: name = m.group(1) exprs.append(f'(dom-dispatch {ref(name)} "click" nil)') continue # Pattern: var.dispatchEvent(new CustomEvent("name")) m = re.match(r'(\w+)\.dispatchEvent\(new CustomEvent\("(\w+)"\)\)', part) if m: exprs.append(f'(dom-dispatch {ref(m.group(1))} "{m.group(2)}" nil)') continue # Pattern: var.dispatchEvent(new CustomEvent("name", {detail: {...}})) m = re.match(r'(\w+)\.dispatchEvent\(new CustomEvent\("(\w+)"', part) if m: exprs.append(f'(dom-dispatch {ref(m.group(1))} "{m.group(2)}" nil)') continue # Pattern: var.setAttribute("name", "value") m = re.match(r'(\w+)\.setAttribute\("(\w+)",\s*"([^"]*)"\)', part) if m: exprs.append(f'(dom-set-attr {ref(m.group(1))} "{m.group(2)}" "{m.group(3)}")') continue # Pattern: var.focus() m = re.match(r'(\w+)\.focus\(\)', part) if m: exprs.append(f'(dom-focus {ref(m.group(1))})') continue # Pattern: var.appendChild(document.createElement("TAG")) m = re.match(r'(\w+)\.appendChild\(document\.createElement\("(\w+)"\)', part) if m: exprs.append(f'(dom-append {ref(m.group(1))} (dom-create-element "{m.group(2)}"))') continue # Skip unrecognized exprs.append(f';; SKIP action: {part[:60]}') return exprs def parse_checks(check): """Convert Chai assertions to SX assert forms. Returns list of SX expressions. Only keeps post-action assertions (last occurrence per expression).""" if not check or check == '(no explicit assertion)': return [] all_checks = [] for part in check.split(' && '): part = part.strip() if not part: continue # Pattern: var.classList.contains("cls").should.equal(bool) m = re.match(r'(\w+)\.classList\.contains\("([^"]+)"\)\.should\.equal\((true|false)\)', part) if m: name, cls, expected = m.group(1), m.group(2), m.group(3) if expected == 'true': all_checks.append(('class', name, cls, True)) else: all_checks.append(('class', name, cls, False)) continue # Pattern: var.innerHTML.should.equal("value") m = re.match(r'(\w+)\.innerHTML\.should\.equal\("([^"]*)"\)', part) if m: all_checks.append(('innerHTML', m.group(1), m.group(2), None)) continue # Pattern: var.innerHTML.should.equal(value) — non-string m = re.match(r'(\w+)\.innerHTML\.should\.equal\((.+)\)', part) if m: all_checks.append(('innerHTML', m.group(1), m.group(2), None)) continue # Pattern: var.textContent.should.equal("value") m = re.match(r'(\w+)\.textContent\.should\.equal\("([^"]*)"\)', part) if m: all_checks.append(('textContent', m.group(1), m.group(2), None)) continue # Pattern: var.style.prop.should.equal("value") m = re.match(r'(\w+)\.style\.(\w+)\.should\.equal\("([^"]*)"\)', part) if m: all_checks.append(('style', m.group(1), m.group(2), m.group(3))) continue # Pattern: var.getAttribute("name").should.equal("value") m = re.match(r'(\w+)\.getAttribute\("([^"]+)"\)\.should\.equal\("([^"]*)"\)', part) if m: all_checks.append(('attr', m.group(1), m.group(2), m.group(3))) continue # Pattern: var.hasAttribute("name").should.equal(bool) m = re.match(r'(\w+)\.hasAttribute\("([^"]+)"\)\.should\.equal\((true|false)\)', part) if m: all_checks.append(('hasAttr', m.group(1), m.group(2), m.group(3) == 'true')) continue # Pattern: getComputedStyle(var).prop.should.equal("value") m = re.match(r'getComputedStyle\((\w+)\)\.(\w+)\.should\.equal\("([^"]*)"\)', part) if m: all_checks.append(('computedStyle', m.group(1), m.group(2), m.group(3))) continue # Pattern: var.parentElement assert m = re.match(r'assert\.isNull\((\w+)\.parentElement\)', part) if m: all_checks.append(('noParent', m.group(1), None, None)) continue m = re.match(r'assert\.isNotNull\((\w+)\.parentElement\)', part) if m: all_checks.append(('hasParent', m.group(1), None, None)) continue # Pattern: var.value.should.equal("value") — input value m = re.match(r'(\w+)\.value\.should\.equal\("([^"]*)"\)', part) if m: all_checks.append(('value', m.group(1), m.group(2), None)) continue # Skip unrecognized all_checks.append(('skip', part[:60], None, None)) # Deduplicate: keep last per (type, name, key) seen = {} for c in all_checks: key = (c[0], c[1], c[2] if c[0] == 'class' else None) seen[key] = c return list(seen.values()) def ref(name): """Convert a JS variable name to SX element reference. For IDs we use dom-query-by-id at runtime (safer than variable refs). For tags we use the let-bound variable.""" tags = {'div', 'form', 'button', 'input', 'span', 'p', 'a', 'section', 'ul', 'li'} if name in tags: return f'_el-{name}' # ID references — use dom-query-by-id for reliability return f'(dom-query-by-id "{name}")' def check_to_sx(check): """Convert a parsed check tuple to an SX assertion.""" typ, name, key, val = check r = ref(name) if typ == 'class' and val: return f'(assert (dom-has-class? {r} "{key}"))' elif typ == 'class' and not val: return f'(assert (not (dom-has-class? {r} "{key}")))' elif typ == 'innerHTML': escaped = key.replace('"', '\\"') if isinstance(key, str) else key return f'(assert= "{escaped}" (dom-inner-html {r}))' elif typ == 'textContent': escaped = key.replace('"', '\\"') return f'(assert= "{escaped}" (dom-text-content {r}))' elif typ == 'style': return f'(assert= "{val}" (dom-get-style {r} "{key}"))' elif typ == 'attr': return f'(assert= "{val}" (dom-get-attr {r} "{key}"))' elif typ == 'hasAttr' and val: return f'(assert (dom-has-attr? {r} "{key}"))' elif typ == 'hasAttr' and not val: return f'(assert (not (dom-has-attr? {r} "{key}")))' elif typ == 'computedStyle': # Can't reliably test computed styles in sandbox return f';; SKIP computed style: {name}.{key} == {val}' elif typ == 'noParent': return f'(assert (nil? (dom-parent {r})))' elif typ == 'hasParent': return f'(assert (not (nil? (dom-parent {r}))))' elif typ == 'value': return f'(assert= "{key}" (dom-get-prop {r} "value"))' else: return f';; SKIP check: {typ} {name} {key} {val}' def generate_test(test, idx): """Generate SX deftest for an upstream test.""" elements = parse_html(test['html']) actions = parse_action(test['action']) checks = parse_checks(test['check']) if not elements and not test.get('html', '').strip(): # eval-only test — no HTML at all return None # Will get a failing stub if not elements: return None # HTML exists but couldn't parse it lines = [] lines.append(f' (deftest "{test["name"]}"') lines.append(' (hs-cleanup!)') # Assign unique variable names to each element var_names = [] used_names = set() for i, el in enumerate(elements): if el['id']: var = f'_el-{el["id"]}' else: var = f'_el-{el["tag"]}' # Ensure uniqueness if var in used_names: var = f'{var}{i}' used_names.add(var) var_names.append(var) # Create elements bindings = [] for i, el in enumerate(elements): bindings.append(f'({var_names[i]} (dom-create-element "{el["tag"]}"))') # Build let block lines.append(f' (let ({" ".join(bindings)})') # Set attributes and append for i, el in enumerate(elements): var = var_names[i] if el['id']: lines.append(f' (dom-set-attr {var} "id" "{el["id"]}")') for cls in el['classes']: lines.append(f' (dom-add-class {var} "{cls}")') if el['hs']: hs_val = el['hs'] # Clean up the HS source for SX string embedding hs_val = hs_val.replace('\\', '').replace('\n', ' ').strip() if not hs_val: continue # Double quotes in HS source → use single-quoted SX string if '"' in hs_val: # Can't embed in SX string — wrap in a comment and skip activation lines.append(f' ;; HS source contains quotes: {hs_val[:60]}') continue lines.append(f' (dom-set-attr {var} "_" "{hs_val}")') for aname, aval in el['attrs'].items(): # Skip attributes with characters that can't be embedded in SX strings if '\\' in aval or '\n' in aval or aname.startswith('[') or '"' in aval: lines.append(f' ;; SKIP attr {aname} (contains special chars)') continue lines.append(f' (dom-set-attr {var} "{aname}" "{aval}")') lines.append(f' (dom-append (dom-body) {var})') if el['hs']: lines.append(f' (hs-activate! {var})') # Actions for action in actions: lines.append(f' {action}') # Assertions for check in checks: sx = check_to_sx(check) lines.append(f' {sx}') lines.append(' ))') # close let + deftest return '\n'.join(lines) # Generate the file output = [] output.append(';; Hyperscript behavioral tests — auto-generated from upstream _hyperscript test suite') output.append(';; Source: spec/tests/hyperscript-upstream-tests.json (346 tests)') output.append(';; DO NOT EDIT — regenerate with: python3 tests/playwright/generate-sx-tests.py') output.append('') output.append(';; ── Test helpers ──────────────────────────────────────────────────') output.append('') output.append('(define hs-test-el') output.append(' (fn (tag hs-src)') output.append(' (let ((el (dom-create-element tag)))') output.append(' (dom-set-attr el "_" hs-src)') output.append(' (dom-append (dom-body) el)') output.append(' (hs-activate! el)') output.append(' el)))') output.append('') output.append('(define hs-cleanup!') output.append(' (fn ()') output.append(' (dom-set-inner-html (dom-body) "")))') output.append('') # Group by category from collections import OrderedDict categories = OrderedDict() for t in raw_tests: cat = t['category'] if cat not in categories: categories[cat] = [] categories[cat].append(t) total = 0 skipped = 0 for cat, tests in categories.items(): output.append(f';; ── {cat} ({len(tests)} tests) ──') output.append(f'(defsuite "hs-upstream-{cat}"') for i, t in enumerate(tests): sx = generate_test(t, i) if sx: output.append(sx) total += 1 else: # Generate a failing test stub so the gap is visible safe_name = t['name'].replace('"', "'") output.append(f' (deftest "{safe_name}"') output.append(f' (error "NOT IMPLEMENTED: test HTML could not be parsed into SX"))') total += 1 output.append(')') # close defsuite output.append('') with open(OUTPUT, 'w') as f: f.write('\n'.join(output)) print(f'Generated {total} tests ({skipped} skipped) -> {OUTPUT}') for cat, tests in categories.items(): print(f' {cat}: {len(tests)}')