Restore hyperscript work on stable site base (908f4f80)
Reset to last known-good state (908f4f80) where links, stepper, and
islands all work, then recovered all hyperscript implementation,
conformance tests, behavioral tests, Playwright specs, site sandbox,
IO-aware server loading, and upstream test suite from f271c88a.
Excludes runtime changes (VM resolve hook, VmSuspended browser handler,
sx_ref.ml guard recovery) that need careful re-integration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
386
tests/playwright/generate-sx-tests.py
Normal file
386
tests/playwright/generate-sx-tests.py
Normal file
@@ -0,0 +1,386 @@
|
||||
#!/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)}')
|
||||
Reference in New Issue
Block a user