Files
rose-ash/tests/playwright/generate-sx-tests.py
giles ce4579badb Generator: context-aware variable refs — 444/831 (53%, +20)
Fixed ref() to map upstream JS variable names to let-bound SX variables
using element context (tag→var, id→var, make-return→last-var). Fixes
if (0→14/19), put (14→18), on (20→23), and other categories where the
upstream test uses make() return variables like d1, div, btn.

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

404 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, ref):
"""Convert upstream action to SX. Returns list of SX expressions."""
if not action or action == '(see body)':
return []
exprs = []
for part in action.split(';'):
part = part.strip()
if not part:
continue
m = re.match(r'(\w+)\.click\(\)', part)
if m:
exprs.append(f'(dom-dispatch {ref(m.group(1))} "click" nil)')
continue
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
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
m = re.match(r'(\w+)\.focus\(\)', part)
if m:
exprs.append(f'(dom-focus {ref(m.group(1))})')
continue
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
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 make_ref_fn(elements, var_names):
"""Create a ref function that maps upstream JS variable names to SX let-bound variables.
Upstream patterns:
- 'div', 'form' etc. → last element with that tag (the make() return value)
- 'd1', 'bar', 'p1' etc. → element with that ID, or last element if no ID match
"""
# Build mappings
tag_to_var = {} # tag -> last var with that tag
id_to_var = {} # id -> var
last_var = var_names[-1] if var_names else '_el-div'
for i, el in enumerate(elements):
tag_to_var[el['tag']] = var_names[i]
if el['id']:
id_to_var[el['id']] = var_names[i]
tags = {'div', 'form', 'button', 'input', 'span', 'p', 'a', 'section',
'ul', 'li', 'select', 'textarea', 'details', 'dialog', 'template'}
def ref(name):
if name in tags:
return tag_to_var.get(name, last_var)
if name in id_to_var:
return id_to_var[name]
# make() return variable pattern: d1, d2, div1, btn1, etc.
# These refer to the last element created by make()
if re.match(r'^[a-z]+\d*$', name) and len(elements) > 0:
# If there's an element with this as id, use dom-query-by-id
# Otherwise it's the make() return var — use last element
return last_var
return f'(dom-query-by-id "{name}")'
return ref
def check_to_sx(check, ref):
"""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'])
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 ref function with element context
ref = make_ref_fn(elements, var_names)
# Parse actions and checks with context-aware ref
actions = parse_action(test['action'], ref)
checks = parse_checks(test['check'])
# 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}")')
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, ref)
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)}')