Files
rose-ash/tests/playwright/generate-hs-tests.py
giles 7492ceac4e 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>
2026-04-09 19:29:56 +00:00

206 lines
7.5 KiB
Python

#!/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}')