Files
rose-ash/tests/playwright/generate-sx-tests.py
giles 737964be89 Honest test suite: 424/831 (51%) — all tests run, timeouts fail visibly
Rewrote test architecture: deferred execution. Tests register thunks during
file load (try-call redefined to append to _test-registry), then the
Playwright loop runs each individually with 3s timeout via Promise.race.
Hanging tests (parser infinite loops) fail with TIMEOUT and trigger page
reboot. No tests are hidden or skipped.

Fixed generator: proper quote escaping for HS sources with embedded quotes,
sanitized comments to avoid SX parser special chars.

831 tests registered, 424 pass, 407 fail honestly:
- 22 perfect categories (empty, dialog, morph, default, reset, scroll, etc.)
- Major gaps: if 0/19, wait 0/7, take 0/12, repeat 2/30, set 4/25
- Timeout failures from parser hangs on unsupported syntax

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:54:01 +00:00

390 lines
14 KiB
Python

#!/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
# Sanitize comment — remove all chars that SX parser treats specially
safe = re.sub(r'[\'\"$@`(),;\\#\[\]{}]', '_', part[:40])
exprs.append(f';; SKIP action: {safe}')
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}'
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}'
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']
hs_val = hs_val.replace('\\', '').replace('\n', ' ').replace('\t', ' ').strip()
if not hs_val:
continue
# Skip malformed values (HTML parser artifacts starting/ending with quotes)
if hs_val.startswith('"') or hs_val.endswith('"'):
lines.append(f' ;; HS source has bare quotes — HTML parse artifact')
continue
# Escape for SX double-quoted string
hs_escaped = hs_val.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f' (dom-set-attr {var} "_" "{hs_escaped}")')
all_hs_sources.add(hs_escaped)
for aname, aval in el['attrs'].items():
if '\\' in aval or '\n' in aval or aname.startswith('['):
lines.append(f' ;; SKIP attr {aname} (contains special chars)')
continue
aval_escaped = aval.replace('"', '\\"')
lines.append(f' (dom-set-attr {var} "{aname}" "{aval_escaped}")')
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)}')