- Fix host-set → host-set! in emit-inc/emit-dec (increment/decrement properties) - Implement empty/clear command: parser dispatch, compiler, polymorphic runtime - Implement swap command: parser dispatch, compiler (let+do temp swap pattern) - Add parse-compound-event-name: joins dot/colon tokens (example.event, htmx:load) - Add hs-compile to source parser (was only in WASM deploy copy) - Add clear/swap to tokenizer keywords and cmd-kw? list - Generator: fix run() with extra args, String.raw support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
845 lines
32 KiB
Python
845 lines
32 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.
|
|
|
|
Handles two assertion formats:
|
|
- Chai-style (.should.equal / assert.*) — from v0.9.14 master tests
|
|
- Playwright-style (toHaveText / toHaveClass / etc.) — from dev branch tests (have `body` field)
|
|
|
|
Usage: python3 tests/playwright/generate-sx-tests.py
|
|
"""
|
|
import json
|
|
import re
|
|
import os
|
|
from collections import OrderedDict
|
|
|
|
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)
|
|
|
|
# ── HTML parsing ──────────────────────────────────────────────────
|
|
|
|
def parse_html(html):
|
|
"""Parse HTML into list of element dicts with parent-child relationships.
|
|
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),
|
|
'children': [], 'parent_idx': None
|
|
}
|
|
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
|
|
# Track parent-child relationship
|
|
if stack:
|
|
parent = stack[-1]
|
|
# Find parent's index in elements list
|
|
parent_idx = None
|
|
for i, e in enumerate(elements):
|
|
if e is parent:
|
|
parent_idx = i
|
|
break
|
|
el['parent_idx'] = parent_idx
|
|
parent['children'].append(len(elements))
|
|
stack.append(el)
|
|
elements.append(el)
|
|
|
|
def handle_endtag(self, tag):
|
|
if stack and stack[-1]['tag'] == tag:
|
|
stack.pop()
|
|
|
|
def handle_data(self, data):
|
|
# Only capture text for elements with no children
|
|
if stack and len(stack[-1]['children']) == 0:
|
|
stack[-1]['inner'] += data.strip()
|
|
|
|
Parser().feed(html)
|
|
return elements
|
|
|
|
|
|
# ── Variable naming ───────────────────────────────────────────────
|
|
|
|
def assign_var_names(elements):
|
|
"""Assign unique SX variable names to elements."""
|
|
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"]}'
|
|
if var in used_names:
|
|
var = f'{var}{i}'
|
|
used_names.add(var)
|
|
var_names.append(var)
|
|
return var_names
|
|
|
|
|
|
# ── Chai-style parsers (v0.9.14 master tests) ────────────────────
|
|
|
|
def parse_action(action, ref):
|
|
"""Convert upstream Chai-style 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
|
|
|
|
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
|
|
|
|
m = re.match(r'(\w+)\.innerHTML\.should\.equal\("([^"]*)"\)', part)
|
|
if m:
|
|
all_checks.append(('innerHTML', m.group(1), m.group(2), None))
|
|
continue
|
|
|
|
m = re.match(r'(\w+)\.innerHTML\.should\.equal\((.+)\)', part)
|
|
if m:
|
|
all_checks.append(('innerHTML', m.group(1), m.group(2), None))
|
|
continue
|
|
|
|
m = re.match(r'(\w+)\.textContent\.should\.equal\("([^"]*)"\)', part)
|
|
if m:
|
|
all_checks.append(('textContent', m.group(1), m.group(2), None))
|
|
continue
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
m = re.match(r'(\w+)\.value\.should\.equal\("([^"]*)"\)', part)
|
|
if m:
|
|
all_checks.append(('value', m.group(1), m.group(2), None))
|
|
continue
|
|
|
|
all_checks.append(('skip', part[:60], None, None))
|
|
|
|
# Deduplicate: keep last per (element, property).
|
|
# Pre-action and post-action assertions for the same property get the same key,
|
|
# so only the post-action assertion (the last one) survives.
|
|
seen = {}
|
|
for c in all_checks:
|
|
typ, name = c[0], c[1]
|
|
if typ in ('class',):
|
|
key = (name, 'class', c[2])
|
|
elif typ in ('innerHTML', 'textContent'):
|
|
key = (name, 'content')
|
|
elif typ in ('style', 'computedStyle'):
|
|
key = (name, 'style', c[2])
|
|
elif typ in ('attr', 'hasAttr'):
|
|
key = (name, 'attr', c[2])
|
|
elif typ in ('noParent', 'hasParent'):
|
|
key = (name, 'parent')
|
|
elif typ in ('value',):
|
|
key = (name, 'value')
|
|
else:
|
|
key = (typ, name, c[2])
|
|
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 naming conventions:
|
|
- div, form, button, select — first element of that tag type
|
|
- d1, d2, d3 — elements by position (1-indexed)
|
|
- div1, div2, div3 — divs by position among same tag (1-indexed)
|
|
- bar, btn, A, B — elements by ID
|
|
"""
|
|
# Map tag → first UNNAMED top-level element of that tag (no id)
|
|
tag_to_unnamed = {}
|
|
# Map tag → list of vars for top-level elements of that tag (ordered)
|
|
tag_to_all = {}
|
|
id_to_var = {}
|
|
# Top-level element vars for positional refs (d1, d2, ...)
|
|
top_level_vars = []
|
|
first_var = var_names[0] if var_names else '_el-div'
|
|
|
|
for i, el in enumerate(elements):
|
|
tag = el['tag']
|
|
if el['id']:
|
|
id_to_var[el['id']] = var_names[i]
|
|
# Only use top-level elements for tag/positional mapping
|
|
if el.get('depth', 0) == 0:
|
|
top_level_vars.append(var_names[i])
|
|
if tag not in tag_to_unnamed and not el['id']:
|
|
tag_to_unnamed[tag] = var_names[i]
|
|
if tag not in tag_to_all:
|
|
tag_to_all[tag] = []
|
|
tag_to_all[tag].append(var_names[i])
|
|
|
|
tags = {'div', 'form', 'button', 'input', 'span', 'p', 'a', 'section',
|
|
'ul', 'li', 'select', 'textarea', 'details', 'dialog', 'template',
|
|
'output'}
|
|
|
|
def ref(name):
|
|
# Exact ID match first
|
|
if name in id_to_var:
|
|
return id_to_var[name]
|
|
|
|
# Bare tag name → first UNNAMED element of that tag (upstream convention:
|
|
# named elements use their ID, unnamed use their tag)
|
|
if name in tags:
|
|
if name in tag_to_unnamed:
|
|
return tag_to_unnamed[name]
|
|
# Fallback: first element of that tag (even if named)
|
|
return tag_to_all.get(name, [first_var])[0]
|
|
|
|
# Tag + number: div1→1st div, div2→2nd div, form1→1st form, etc.
|
|
m = re.match(r'^([a-z]+)(\d+)$', name)
|
|
if m:
|
|
tag_part, num = m.group(1), int(m.group(2))
|
|
if tag_part in tag_to_all:
|
|
idx = num - 1 # 1-indexed
|
|
if 0 <= idx < len(tag_to_all[tag_part]):
|
|
return tag_to_all[tag_part][idx]
|
|
|
|
# Positional: d1→1st top-level element, d2→2nd, d3→3rd, etc.
|
|
m = re.match(r'^d(\d+)$', name)
|
|
if m:
|
|
idx = int(m.group(1)) - 1 # 1-indexed
|
|
if 0 <= idx < len(top_level_vars):
|
|
return top_level_vars[idx]
|
|
|
|
# Short aliases: btn → look up as ID
|
|
if name == 'btn':
|
|
return id_to_var.get('btn', tag_to_unnamed.get('button', first_var))
|
|
|
|
# Single-letter or short lowercase → try as ID, fallback to first element
|
|
if re.match(r'^[a-z]+$', name) and len(elements) > 0:
|
|
return first_var
|
|
|
|
return f'(dom-query-by-id "{name}")'
|
|
|
|
return ref
|
|
|
|
|
|
def check_to_sx(check, ref):
|
|
"""Convert a parsed Chai 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= (dom-inner-html {r}) "{escaped}")'
|
|
elif typ == 'textContent':
|
|
escaped = key.replace('"', '\\"')
|
|
return f'(assert= (dom-text-content {r}) "{escaped}")'
|
|
elif typ == 'style':
|
|
return f'(assert= (dom-get-style {r} "{key}") "{val}")'
|
|
elif typ == 'attr':
|
|
return f'(assert= (dom-get-attr {r} "{key}") "{val}")'
|
|
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':
|
|
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= (dom-get-prop {r} "value") "{key}")'
|
|
else:
|
|
return f';; SKIP check: {typ} {name}'
|
|
|
|
|
|
# ── Playwright-style body parser (dev branch tests) ──────────────
|
|
|
|
def selector_to_sx(selector, elements, var_names):
|
|
"""Convert a CSS selector from find('selector') to SX DOM lookup expression."""
|
|
selector = selector.strip("'\"")
|
|
if selector.startswith('#'):
|
|
# ID selector — might be compound like '#a output'
|
|
if ' ' in selector:
|
|
return f'(dom-query "{selector}")'
|
|
return f'(dom-query-by-id "{selector[1:]}")'
|
|
if selector.startswith('.'):
|
|
return f'(dom-query "{selector}")'
|
|
# Try tag match to a let-bound variable
|
|
for i, el in enumerate(elements):
|
|
if el['tag'] == selector and i < len(var_names):
|
|
return var_names[i]
|
|
# Fallback: query by tag
|
|
return f'(dom-query "{selector}")'
|
|
|
|
|
|
def parse_pw_args(args_str):
|
|
"""Parse Playwright assertion arguments like 'foo', "bar" or "name", "value"."""
|
|
args = []
|
|
for m in re.finditer(r"""(['"])(.*?)\1""", args_str):
|
|
args.append(m.group(2))
|
|
return args
|
|
|
|
|
|
def pw_assertion_to_sx(target, negated, assert_type, args_str):
|
|
"""Convert a Playwright assertion to SX."""
|
|
args = parse_pw_args(args_str)
|
|
|
|
if assert_type == 'toHaveText':
|
|
val = args[0] if args else ''
|
|
escaped = val.replace('\\', '\\\\').replace('"', '\\"')
|
|
if negated:
|
|
return f'(assert (!= (dom-text-content {target}) "{escaped}"))'
|
|
return f'(assert= (dom-text-content {target}) "{escaped}")'
|
|
|
|
elif assert_type == 'toHaveAttribute':
|
|
attr_name = args[0] if args else ''
|
|
if len(args) >= 2:
|
|
attr_val = args[1].replace('\\', '\\\\').replace('"', '\\"')
|
|
if negated:
|
|
return f'(assert (!= (dom-get-attr {target} "{attr_name}") "{attr_val}"))'
|
|
return f'(assert= (dom-get-attr {target} "{attr_name}") "{attr_val}")'
|
|
else:
|
|
if negated:
|
|
return f'(assert (not (dom-has-attr? {target} "{attr_name}")))'
|
|
return f'(assert (dom-has-attr? {target} "{attr_name}"))'
|
|
|
|
elif assert_type == 'toHaveClass':
|
|
cls = args[0] if args else ''
|
|
if not cls:
|
|
# Handle regex like /outer-clicked/
|
|
m = re.match(r'/(.+?)/', args_str)
|
|
if m:
|
|
cls = m.group(1)
|
|
if negated:
|
|
return f'(assert (not (dom-has-class? {target} "{cls}")))'
|
|
return f'(assert (dom-has-class? {target} "{cls}"))'
|
|
|
|
elif assert_type == 'toHaveCSS':
|
|
prop = args[0] if args else ''
|
|
val = args[1] if len(args) >= 2 else ''
|
|
escaped = val.replace('\\', '\\\\').replace('"', '\\"')
|
|
if negated:
|
|
return f'(assert (!= (dom-get-style {target} "{prop}") "{escaped}"))'
|
|
return f'(assert= (dom-get-style {target} "{prop}") "{escaped}")'
|
|
|
|
elif assert_type == 'toHaveValue':
|
|
val = args[0] if args else ''
|
|
escaped = val.replace('\\', '\\\\').replace('"', '\\"')
|
|
if negated:
|
|
return f'(assert (!= (dom-get-prop {target} "value") "{escaped}"))'
|
|
return f'(assert= (dom-get-prop {target} "value") "{escaped}")'
|
|
|
|
elif assert_type == 'toBeVisible':
|
|
if negated:
|
|
return f'(assert (not (dom-visible? {target})))'
|
|
return f'(assert (dom-visible? {target}))'
|
|
|
|
elif assert_type == 'toBeHidden':
|
|
if negated:
|
|
return f'(assert (dom-visible? {target}))'
|
|
return f'(assert (not (dom-visible? {target})))'
|
|
|
|
elif assert_type == 'toBeChecked':
|
|
if negated:
|
|
return f'(assert (not (dom-get-prop {target} "checked")))'
|
|
return f'(assert (dom-get-prop {target} "checked"))'
|
|
|
|
return None
|
|
|
|
|
|
def parse_dev_body(body, elements, var_names):
|
|
"""Parse Playwright test body to extract actions and post-action assertions.
|
|
|
|
Returns a single ordered list of SX expression strings (actions and assertions
|
|
interleaved in their original order). Pre-action assertions are skipped.
|
|
"""
|
|
ops = []
|
|
found_first_action = False
|
|
|
|
for line in body.split('\n'):
|
|
line = line.strip()
|
|
|
|
# Skip comments
|
|
if line.startswith('//'):
|
|
continue
|
|
|
|
# Action: find('selector').click() or .dispatchEvent('event')
|
|
m = re.search(r"find\((['\"])(.+?)\1\)\.(click|dispatchEvent)\(([^)]*)\)", line)
|
|
if m and 'expect' not in line:
|
|
found_first_action = True
|
|
selector = m.group(2)
|
|
action_type = m.group(3)
|
|
action_arg = m.group(4).strip("'\"")
|
|
target = selector_to_sx(selector, elements, var_names)
|
|
if action_type == 'click':
|
|
ops.append(f'(dom-dispatch {target} "click" nil)')
|
|
elif action_type == 'dispatchEvent':
|
|
ops.append(f'(dom-dispatch {target} "{action_arg}" nil)')
|
|
continue
|
|
|
|
# Skip lines before first action (pre-checks, setup)
|
|
if not found_first_action:
|
|
continue
|
|
|
|
# Assertion: expect(find('selector')).[not.]toHaveText("value")
|
|
m = re.search(
|
|
r"expect\(find\((['\"])(.+?)\1\)\)\.(not\.)?"
|
|
r"(toHaveText|toHaveClass|toHaveCSS|toHaveAttribute|toHaveValue|toBeVisible|toBeHidden|toBeChecked)"
|
|
r"\(([^)]*)\)",
|
|
line
|
|
)
|
|
if m:
|
|
selector = m.group(2)
|
|
negated = bool(m.group(3))
|
|
assert_type = m.group(4)
|
|
args_str = m.group(5)
|
|
target = selector_to_sx(selector, elements, var_names)
|
|
sx = pw_assertion_to_sx(target, negated, assert_type, args_str)
|
|
if sx:
|
|
ops.append(sx)
|
|
continue
|
|
|
|
return ops
|
|
|
|
|
|
# ── Test generation ───────────────────────────────────────────────
|
|
|
|
def process_hs_val(hs_val):
|
|
"""Process a raw HS attribute value: collapse whitespace, insert 'then' separators."""
|
|
hs_val = hs_val.replace('\\', '')
|
|
cmd_kws = r'(?:set|put|get|add|remove|toggle|hide|show|if|repeat|for|wait|send|trigger|log|call|take|throw|return|append|tell|go|halt|settle|increment|decrement|fetch|make|install|measure|empty|reset|swap|default|morph|render|scroll|focus|select|pick|beep!)'
|
|
hs_val = re.sub(r'\s{2,}(?=' + cmd_kws + r'\b)', ' then ', hs_val)
|
|
hs_val = re.sub(r'\s*[\n\r]\s*', ' then ', hs_val)
|
|
hs_val = re.sub(r'\s+', ' ', hs_val)
|
|
hs_val = re.sub(r'(then\s*)+then', 'then', hs_val)
|
|
hs_val = re.sub(r'\bon (\w[\w.:+-]*) then\b', r'on \1 ', hs_val)
|
|
hs_val = re.sub(r'(\bin \[.*?\]) then\b', r'\1 ', hs_val)
|
|
hs_val = re.sub(r'\btimes then\b', 'times ', hs_val)
|
|
return hs_val.strip()
|
|
|
|
|
|
def emit_element_setup(lines, elements, var_names):
|
|
"""Emit SX for creating elements, setting attributes, appending to DOM, and activating.
|
|
|
|
Three phases to ensure correct ordering:
|
|
1. Set attributes/content on all elements
|
|
2. Append elements to their parents (children first, then parents to body)
|
|
3. Activate HS handlers (all elements in DOM)
|
|
"""
|
|
hs_elements = [] # indices of elements with valid HS
|
|
|
|
# Phase 1: Set attributes, classes, HS, inner text
|
|
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 = process_hs_val(el['hs'])
|
|
if not hs_val:
|
|
pass # no HS to set
|
|
elif hs_val.startswith('"') or (hs_val.endswith('"') and '<' in hs_val):
|
|
lines.append(f' ;; HS source has bare quotes or embedded HTML')
|
|
else:
|
|
hs_escaped = hs_val.replace('\\', '\\\\').replace('"', '\\"')
|
|
lines.append(f' (dom-set-attr {var} "_" "{hs_escaped}")')
|
|
hs_elements.append(i)
|
|
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}")')
|
|
if el['inner']:
|
|
inner_escaped = el['inner'].replace('\\', '\\\\').replace('"', '\\"')
|
|
lines.append(f' (dom-set-inner-html {var} "{inner_escaped}")')
|
|
|
|
# Phase 2: Append elements (children to parents, roots to body)
|
|
for i, el in enumerate(elements):
|
|
var = var_names[i]
|
|
if el['parent_idx'] is not None:
|
|
parent_var = var_names[el['parent_idx']]
|
|
lines.append(f' (dom-append {parent_var} {var})')
|
|
else:
|
|
lines.append(f' (dom-append (dom-body) {var})')
|
|
|
|
# Phase 3: Activate HS handlers (all elements now in DOM)
|
|
for i in hs_elements:
|
|
lines.append(f' (hs-activate! {var_names[i]})')
|
|
|
|
|
|
def generate_test_chai(test, elements, var_names, idx):
|
|
"""Generate SX deftest using Chai-style action/check fields."""
|
|
ref = make_ref_fn(elements, var_names)
|
|
actions = parse_action(test['action'], ref)
|
|
checks = parse_checks(test['check'])
|
|
|
|
lines = []
|
|
lines.append(f' (deftest "{test["name"]}"')
|
|
lines.append(' (hs-cleanup!)')
|
|
|
|
bindings = [f'({var_names[i]} (dom-create-element "{el["tag"]}"))' for i, el in enumerate(elements)]
|
|
lines.append(f' (let ({" ".join(bindings)})')
|
|
|
|
emit_element_setup(lines, elements, var_names)
|
|
|
|
for action in actions:
|
|
lines.append(f' {action}')
|
|
for check in checks:
|
|
sx = check_to_sx(check, ref)
|
|
lines.append(f' {sx}')
|
|
|
|
lines.append(' ))')
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def generate_test_pw(test, elements, var_names, idx):
|
|
"""Generate SX deftest using Playwright-style body field."""
|
|
ops = parse_dev_body(test['body'], elements, var_names)
|
|
|
|
lines = []
|
|
lines.append(f' (deftest "{test["name"]}"')
|
|
lines.append(' (hs-cleanup!)')
|
|
|
|
bindings = [f'({var_names[i]} (dom-create-element "{el["tag"]}"))' for i, el in enumerate(elements)]
|
|
lines.append(f' (let ({" ".join(bindings)})')
|
|
|
|
emit_element_setup(lines, elements, var_names)
|
|
|
|
for op in ops:
|
|
lines.append(f' {op}')
|
|
|
|
lines.append(' ))')
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def js_val_to_sx(val):
|
|
"""Convert a JS literal value to SX."""
|
|
val = val.strip()
|
|
if val == 'true': return 'true'
|
|
if val == 'false': return 'false'
|
|
if val in ('null', 'undefined'): return 'nil'
|
|
if val.startswith('"') or val.startswith("'"):
|
|
return '"' + val.strip("\"'") + '"'
|
|
# Arrays: [1, 2, 3] → (list 1 2 3)
|
|
if val.startswith('[') and val.endswith(']'):
|
|
inner = val[1:-1].strip()
|
|
if not inner:
|
|
return '(list)'
|
|
items = [js_val_to_sx(x.strip()) for x in split_top_level(inner)]
|
|
return '(list ' + ' '.join(items) + ')'
|
|
try:
|
|
float(val)
|
|
return val
|
|
except ValueError:
|
|
return f'"{val}"'
|
|
|
|
|
|
def split_top_level(s):
|
|
"""Split a string by commas, respecting brackets/quotes."""
|
|
parts = []
|
|
depth = 0
|
|
current = []
|
|
in_str = None
|
|
for ch in s:
|
|
if in_str:
|
|
current.append(ch)
|
|
if ch == in_str:
|
|
in_str = None
|
|
elif ch in ('"', "'"):
|
|
in_str = ch
|
|
current.append(ch)
|
|
elif ch in ('(', '[', '{'):
|
|
depth += 1
|
|
current.append(ch)
|
|
elif ch in (')', ']', '}'):
|
|
depth -= 1
|
|
current.append(ch)
|
|
elif ch == ',' and depth == 0:
|
|
parts.append(''.join(current))
|
|
current = []
|
|
else:
|
|
current.append(ch)
|
|
if current:
|
|
parts.append(''.join(current))
|
|
return parts
|
|
|
|
|
|
def extract_hs_expr(raw):
|
|
"""Clean a HS expression extracted from run() call."""
|
|
# Remove surrounding whitespace and newlines
|
|
expr = raw.strip().replace('\n', ' ').replace('\t', ' ')
|
|
# Collapse multiple spaces
|
|
expr = re.sub(r'\s+', ' ', expr)
|
|
# Escape quotes for SX string
|
|
expr = expr.replace('\\', '').replace('"', '\\"')
|
|
return expr
|
|
|
|
|
|
def generate_eval_only_test(test, idx):
|
|
"""Generate SX deftest for no-HTML tests using eval-hs.
|
|
Handles patterns:
|
|
- run("expr").toBe(val) or run("expr", opts).toBe(val)
|
|
- expect(run("expr")).toBe(val) or expect(run("expr", opts)).toBe(val)
|
|
- var result = await run(`expr`, opts); expect(result).toBe(val)
|
|
- run("expr").toEqual([...]) or run("expr").toEqual({...})
|
|
- run("expr").toThrow()
|
|
Also handles String.raw`expr` template literals.
|
|
"""
|
|
body = test.get('body', '')
|
|
lines = []
|
|
safe_name = test["name"].replace('"', "'")
|
|
lines.append(f' (deftest "{safe_name}"')
|
|
|
|
assertions = []
|
|
|
|
# Shared sub-pattern for run() call with optional String.raw and extra args:
|
|
# run(QUOTE expr QUOTE) or run(QUOTE expr QUOTE, opts) or run(String.raw`expr`, opts)
|
|
# Extra args can contain nested parens/braces, so we allow anything non-greedy up to the
|
|
# matching close-paren by tracking that the close-paren follows the quote.
|
|
_Q = r'["\x27`]' # quote character class
|
|
_RUN_OPEN = r'(?:await\s+)?run\((?:String\.raw)?(' + _Q + r')(.+?)\1' # groups: (quote, expr)
|
|
_RUN_ARGS = r'(?:\s*,\s*[^)]*(?:\([^)]*\)[^)]*)*)*' # optional extra args with nested parens
|
|
|
|
# Pattern 1: Inline — expect(run("expr", opts)).toBe(val) or run("expr", opts).toBe(val)
|
|
for m in re.finditer(
|
|
r'(?:expect\()?' + _RUN_OPEN + _RUN_ARGS + r'\)\)?\.toBe\(([^)]+)\)',
|
|
body, re.DOTALL
|
|
):
|
|
hs_expr = extract_hs_expr(m.group(2))
|
|
expected_sx = js_val_to_sx(m.group(3))
|
|
assertions.append(f' (assert= (eval-hs "{hs_expr}") {expected_sx})')
|
|
|
|
# Pattern 1b: Inline — run("expr", opts).toEqual([...])
|
|
for m in re.finditer(
|
|
r'(?:expect\()?' + _RUN_OPEN + _RUN_ARGS + r'\)\)?\.toEqual\((\[.*?\])\)',
|
|
body, re.DOTALL
|
|
):
|
|
hs_expr = extract_hs_expr(m.group(2))
|
|
expected_sx = js_val_to_sx(m.group(3))
|
|
assertions.append(f' (assert= (eval-hs "{hs_expr}") {expected_sx})')
|
|
|
|
# Pattern 1c: Inline — run("expr", opts).toEqual({...})
|
|
if not assertions:
|
|
for m in re.finditer(
|
|
r'(?:expect\()?' + _RUN_OPEN + _RUN_ARGS + r'\)\)?\.toEqual\((\{.*?\})\)',
|
|
body, re.DOTALL
|
|
):
|
|
hs_expr = extract_hs_expr(m.group(2))
|
|
# Object toEqual — emit as dict assertion comment (can't fully convert JS objects to SX)
|
|
obj_str = m.group(3).strip()
|
|
assertions.append(f' ;; TODO: assert= (eval-hs "{hs_expr}") against {obj_str}')
|
|
|
|
# Pattern 2: Two-line — var result = await run(`expr`, opts); expect(result...).toBe/toEqual(val)
|
|
if not assertions:
|
|
run_match = re.search(
|
|
r'(?:var|let|const)\s+\w+\s*=\s*' + _RUN_OPEN + _RUN_ARGS + r'\)',
|
|
body, re.DOTALL
|
|
)
|
|
if run_match:
|
|
hs_expr = extract_hs_expr(run_match.group(2))
|
|
for m in re.finditer(r'\.toBe\(([^)]+)\)', body):
|
|
expected_sx = js_val_to_sx(m.group(1))
|
|
assertions.append(f' (assert= (eval-hs "{hs_expr}") {expected_sx})')
|
|
for m in re.finditer(r'\.toEqual\((\[.*?\])\)', body, re.DOTALL):
|
|
expected_sx = js_val_to_sx(m.group(1))
|
|
assertions.append(f' (assert= (eval-hs "{hs_expr}") {expected_sx})')
|
|
|
|
# Pattern 3: toThrow — expect(() => run("expr")).toThrow()
|
|
for m in re.finditer(
|
|
r'run\((?:String\.raw)?(["\x27`])(.+?)\1\).*?\.toThrow\(\)',
|
|
body, re.DOTALL
|
|
):
|
|
hs_expr = extract_hs_expr(m.group(2))
|
|
assertions.append(f' (assert-throws (eval-hs "{hs_expr}"))')
|
|
|
|
if not assertions:
|
|
return None # Can't convert this body pattern
|
|
|
|
for a in assertions:
|
|
lines.append(a)
|
|
lines.append(' )')
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def generate_test(test, idx):
|
|
"""Generate SX deftest for an upstream test. Dispatches to Chai, PW, or eval-only."""
|
|
elements = parse_html(test['html'])
|
|
|
|
if not elements and not test.get('html', '').strip():
|
|
# No HTML — try eval-only conversion
|
|
return generate_eval_only_test(test, idx)
|
|
if not elements:
|
|
return None
|
|
|
|
var_names = assign_var_names(elements)
|
|
|
|
if test.get('body'):
|
|
return generate_test_pw(test, elements, var_names, idx)
|
|
else:
|
|
return generate_test_chai(test, elements, var_names, idx)
|
|
|
|
|
|
# ── Output generation ─────────────────────────────────────────────
|
|
|
|
output = []
|
|
output.append(';; Hyperscript behavioral tests — auto-generated from upstream _hyperscript test suite')
|
|
output.append(f';; Source: spec/tests/hyperscript-upstream-tests.json ({len(raw_tests)} tests, v0.9.14 + dev)')
|
|
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
|
|
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
|
|
generated_counts = {} # cat -> (generated, stubbed)
|
|
for cat, tests in categories.items():
|
|
output.append(f';; ── {cat} ({len(tests)} tests) ──')
|
|
output.append(f'(defsuite "hs-upstream-{cat}"')
|
|
|
|
cat_gen = 0
|
|
cat_stub = 0
|
|
for i, t in enumerate(tests):
|
|
sx = generate_test(t, i)
|
|
if sx:
|
|
output.append(sx)
|
|
total += 1
|
|
cat_gen += 1
|
|
else:
|
|
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
|
|
cat_stub += 1
|
|
|
|
output.append(')')
|
|
output.append('')
|
|
generated_counts[cat] = (cat_gen, cat_stub)
|
|
|
|
with open(OUTPUT, 'w') as f:
|
|
f.write('\n'.join(output))
|
|
|
|
# Report
|
|
has_body = sum(1 for t in raw_tests if t.get('body'))
|
|
print(f'Generated {total} tests -> {OUTPUT}')
|
|
print(f' Source: {len(raw_tests)} tests ({len(raw_tests) - has_body} Chai-style, {has_body} Playwright-style)')
|
|
print(f' Categories: {len(categories)}')
|
|
for cat, (gen, stub) in generated_counts.items():
|
|
marker = '' if stub == 0 else f' ({stub} stubs)'
|
|
print(f' {cat}: {gen}{marker}')
|