Files
rose-ash/tests/playwright/generate-sx-tests.py
giles 06bed36272 Fix HTML attribute parsing: strip \" delimiters from JSON-extracted HTML
Tests with _=\"...\" attribute delimiters were garbled because
HTMLParser interpreted the backslash-quote as content, not delimiters.
Now html.replace('\"', '"') normalizes before parsing.

Fixes ~15 tests across toggle, transition, and other categories
that were previously running with corrupted HS source.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 21:06:09 +00:00

1274 lines
51 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')
# All gallery pages live as flat files in applications/hyperscript/ with
# dash-joined slugs. The sx_docs routing layer only allows one level of
# page-fn dispatch at a time (call-page in web/request-handler.sx), and the
# hyperscript page-fn is a single-arg make-page-fn — so URLs have to be
# /sx/(applications.(hyperscript.gallery-<theme>-<category>)), not nested.
# The directory named "tests" is also in the server's skip_dirs list, so we
# couldn't use /tests/ anyway.
PAGES_DIR = os.path.join(PROJECT_ROOT, 'sx/sx/applications/hyperscript')
GALLERY_SLUG = 'gallery'
def page_slug(parts):
"""Build a dash-joined slug from path parts (theme, category, ...)."""
return '-'.join([GALLERY_SLUG] + [p for p in parts if p])
def page_url(parts):
"""Build the full /sx/... URL for a gallery slug."""
return f'/sx/(applications.(hyperscript.{page_slug(parts)}))'
# Six themes for grouping categories on the live gallery pages.
# Any category not listed here gets bucketed into 'misc'.
TEST_THEMES = {
'dom': ['add', 'remove', 'toggle', 'set', 'put', 'append', 'hide', 'empty',
'take', 'morph', 'show', 'measure', 'swap', 'focus', 'scroll', 'reset'],
'events': ['on', 'when', 'send', 'tell', 'init', 'bootstrap', 'socket',
'dialog', 'wait', 'halt', 'pick', 'fetch', 'asyncError'],
'expressions': ['comparisonOperator', 'mathOperator', 'logicalOperator',
'asExpression', 'collectionExpressions', 'closest', 'increment',
'queryRef', 'attributeRef', 'objectLiteral', 'no', 'default',
'in', 'splitJoin', 'select'],
'control': ['if', 'repeat', 'go', 'call', 'log', 'settle'],
'reactivity': ['bind', 'live', 'liveTemplate', 'reactive-properties',
'transition', 'resize'],
'language': ['def', 'component', 'parser', 'js', 'scoping', 'evalStatically',
'askAnswer', 'assignableElements',
'relativePositionalExpression', 'cookies', 'dom-scope'],
}
def theme_for_category(category):
for theme, cats in TEST_THEMES.items():
if category in cats:
return theme
return 'misc'
def sx_str(s):
"""Escape a Python string for inclusion as an SX string literal."""
return '"' + s.replace('\\', '\\\\').replace('"', '\\"') + '"'
with open(INPUT) as f:
raw_tests = json.load(f)
# ── HTML parsing ──────────────────────────────────────────────────
def extract_hs_scripts(html):
"""Extract <script type='text/hyperscript'>...</script> content blocks."""
scripts = []
for m in re.finditer(
r"<script\s+type=['\"]text/hyperscript['\"]>(.*?)</script>",
html, re.DOTALL
):
scripts.append(m.group(1).strip())
return scripts
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 script tags before parsing elements (they're handled separately)
html = re.sub(r"<script\s+type=['\"]text/hyperscript['\"]>.*?</script>", '', html, flags=re.DOTALL)
# Remove | separators
html = html.replace(' | ', '')
# Fix escaped attribute delimiters from JSON extraction (\" → ")
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')[.first()/.last()].click/dispatchEvent/fill/check/uncheck/focus()
m = re.search(r"find\((['\"])(.+?)\1\)(?:\.(?:first|last)\(\))?\.(click|dispatchEvent|fill|check|uncheck|focus|selectOption)\(([^)]*)\)", 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)')
elif action_type == 'fill':
escaped = action_arg.replace('\\', '\\\\').replace('"', '\\"')
ops.append(f'(dom-set-prop {target} "value" "{escaped}")')
ops.append(f'(dom-dispatch {target} "input" nil)')
elif action_type == 'check':
ops.append(f'(dom-set-prop {target} "checked" true)')
ops.append(f'(dom-dispatch {target} "change" nil)')
elif action_type == 'uncheck':
ops.append(f'(dom-set-prop {target} "checked" false)')
ops.append(f'(dom-dispatch {target} "change" nil)')
elif action_type == 'focus':
ops.append(f'(dom-focus {target})')
elif action_type == 'selectOption':
escaped = action_arg.replace('\\', '\\\\').replace('"', '\\"')
ops.append(f'(dom-set-prop {target} "value" "{escaped}")')
ops.append(f'(dom-dispatch {target} "change" nil)')
continue
# Skip lines before first action (pre-checks, setup)
if not found_first_action:
continue
# Assertion: expect(find('selector')[.first()/.last()]).[not.]toHaveText("value")
m = re.search(
r"expect\(find\((['\"])(.+?)\1\)(?:\.(?:first|last)\(\))?\)\.(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."""
# Convert escaped newlines/tabs to real whitespace
hs_val = hs_val.replace('\\n', '\n').replace('\\t', ' ')
# Preserve escaped quotes (\" → placeholder), strip remaining backslashes, restore
hs_val = hs_val.replace('\\"', '\x00QUOT\x00')
hs_val = hs_val.replace('\\', '')
hs_val = hs_val.replace('\x00QUOT\x00', '\\"')
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 (?:\[.*?\]|\S+)) then\b', r'\1 ', hs_val)
hs_val = re.sub(r'\btimes then\b', 'times ', hs_val)
hs_val = re.sub(r'\bend then\b', 'end ', hs_val)
return hs_val.strip()
def emit_element_setup(lines, elements, var_names, root='(dom-body)', indent=' '):
"""Emit SX for creating elements, setting attributes, appending to DOM, and activating.
root — where top-level elements get appended. Default (dom-body); for the gallery
card, callers pass a sandbox variable name so the HS runs inside the card, not on
the page body.
Three phases to ensure correct ordering:
1. Set attributes/content on all elements
2. Append elements to their parents (children first, then roots to root)
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'{indent}(dom-set-attr {var} "id" "{el["id"]}")')
for cls in el['classes']:
lines.append(f'{indent}(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'{indent};; HS source has bare quotes or embedded HTML')
else:
hs_escaped = hs_val.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f'{indent}(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'{indent};; SKIP attr {aname} (contains special chars)')
continue
aval_escaped = aval.replace('"', '\\"')
lines.append(f'{indent}(dom-set-attr {var} "{aname}" "{aval_escaped}")')
if el['inner']:
inner_escaped = el['inner'].replace('\\', '\\\\').replace('"', '\\"')
lines.append(f'{indent}(dom-set-inner-html {var} "{inner_escaped}")')
# Phase 2: Append elements (children to parents, roots to `root`)
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'{indent}(dom-append {parent_var} {var})')
else:
lines.append(f'{indent}(dom-append {root} {var})')
# Phase 3: Activate HS handlers (all elements now in DOM)
for i in hs_elements:
lines.append(f'{indent}(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'])
# Extract <script type="text/hyperscript"> blocks
hs_scripts = extract_hs_scripts(test.get('html', ''))
lines = []
lines.append(f' (deftest "{test["name"]}"')
lines.append(' (hs-cleanup!)')
# Compile HS script blocks as setup (def functions etc.)
for script in hs_scripts:
# Clean whitespace
clean = ' '.join(script.split())
escaped = clean.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f' (eval-expr-cek (hs-to-sx (hs-compile "{escaped}")))')
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 + r'(\s*,\s*\{[^}]*(?:\{[^}]*\}[^}]*)?\})?' + r'\)\)?\.toBe\(([^)]+)\)',
body, re.DOTALL
):
hs_expr = extract_hs_expr(m.group(2))
opts_str = m.group(3) or ''
expected_sx = js_val_to_sx(m.group(4))
# Check for { me: X } or { locals: { x: X, y: Y } } in opts
me_match = re.search(r'\bme:\s*(\d+)', opts_str)
locals_match = re.search(r'locals:\s*\{([^}]+)\}', opts_str)
if locals_match:
local_bindings = []
for lm in re.finditer(r'(\w+)\s*:\s*([^,}]+)', locals_match.group(1)):
lname = lm.group(1)
lval = js_val_to_sx(lm.group(2).strip())
local_bindings.append(f'({lname} {lval})')
assertions.append(f' (let ({" ".join(local_bindings)}) (assert= (eval-hs "{hs_expr}") {expected_sx}))')
elif me_match:
me_val = me_match.group(1)
assertions.append(f' (assert= (eval-hs-with-me "{hs_expr}" {me_val}) {expected_sx})')
else:
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))
var_name = re.search(r'(?:var|let|const)\s+(\w+)', body).group(1)
for m in re.finditer(r'expect\((' + re.escape(var_name) + r'(?:\["[^"]+"\]|\.\w+)?)\)\.toBe\(([^)]+)\)', body):
accessor = m.group(1)
expected_sx = js_val_to_sx(m.group(2))
# Check for property access: result["foo"] or result.foo
prop_m = re.search(r'\["([^"]+)"\]|\.(\w+)', accessor[len(var_name):])
if prop_m:
prop = prop_m.group(1) or prop_m.group(2)
assertions.append(f' (assert= (host-get (eval-hs "{hs_expr}") "{prop}") {expected_sx})')
else:
assertions.append(f' (assert= (eval-hs "{hs_expr}") {expected_sx})')
for m in re.finditer(r'expect\(' + re.escape(var_name) + r'(?:\.\w+)?\)\.toEqual\((\[.*?\])\)', body, re.DOTALL):
expected_sx = js_val_to_sx(m.group(1))
assertions.append(f' (assert= (eval-hs "{hs_expr}") {expected_sx})')
# Handle .map(x => x.prop) before toEqual
for m in re.finditer(r'expect\(' + re.escape(var_name) + r'\.map\(\w+\s*=>\s*\w+\.(\w+)\)\)\.toEqual\((\[.*?\])\)', body, re.DOTALL):
prop = m.group(1)
expected_sx = js_val_to_sx(m.group(2))
assertions.append(f' (assert= (map (fn (x) (get x "{prop}")) (eval-hs "{hs_expr}")) {expected_sx})')
# Pattern 2b: run() with locals + evaluate(window.X) + expect().toBe/toEqual
# e.g.: await run(`expr`, {locals: {arr: [1,2,3]}});
# const result = await evaluate(() => window.$test);
# expect(result).toEqual([1,2,3]);
if not assertions:
run_match = re.search(
r'(?:await\s+)?run\((?:String\.raw)?(' + _Q + r')(.+?)\1\s*,\s*\{locals:\s*\{(.*?)\}\}',
body, re.DOTALL
)
if run_match:
hs_expr = extract_hs_expr(run_match.group(2))
locals_str = run_match.group(3).strip()
# Parse locals: {key: val, ...}
local_bindings = []
for lm in re.finditer(r'(\w+)\s*:\s*(.+?)(?:,\s*(?=\w+\s*:)|$)', locals_str):
lname = lm.group(1)
lval = js_val_to_sx(lm.group(2).strip().rstrip(','))
local_bindings.append(f'({lname} {lval})')
# Find expect().toBe() or .toEqual()
for m in re.finditer(r'expect\([^)]*\)\.toBe\(([^)]+)\)', body):
expected_sx = js_val_to_sx(m.group(1))
if local_bindings:
assertions.append(f' (let ({" ".join(local_bindings)}) (eval-hs "{hs_expr}") (assert= it {expected_sx}))')
else:
assertions.append(f' (assert= (eval-hs "{hs_expr}") {expected_sx})')
for m in re.finditer(r'expect\([^)]*\)\.toEqual\((\[.*?\])\)', body, re.DOTALL):
expected_sx = js_val_to_sx(m.group(1))
if local_bindings:
assertions.append(f' (let ({" ".join(local_bindings)}) (eval-hs "{hs_expr}") (assert= it {expected_sx}))')
else:
assertions.append(f' (assert= (eval-hs "{hs_expr}") {expected_sx})')
for m in re.finditer(r'expect\([^)]*\)\.toContain\(([^)]+)\)', body):
expected_sx = js_val_to_sx(m.group(1))
if local_bindings:
assertions.append(f' (let ({" ".join(local_bindings)}) (eval-hs "{hs_expr}") (assert (not (nil? it))))')
else:
assertions.append(f' (assert (not (nil? (eval-hs "{hs_expr}"))))')
for m in re.finditer(r'expect\([^)]*\)\.toHaveLength\((\d+)\)', body):
length = m.group(1)
if local_bindings:
assertions.append(f' (let ({" ".join(local_bindings)}) (eval-hs "{hs_expr}") (assert= (len it) {length}))')
else:
assertions.append(f' (assert= (len (eval-hs "{hs_expr}")) {length})')
# Pattern 2c: evaluate(() => _hyperscript.parse("expr").evalStatically()).toBe(val)
if not assertions:
for m in re.finditer(
r'evaluate\(\(\)\s*=>\s*_hyperscript\.parse\((["\x27])(.+?)\1\)\.evalStatically\(\)\)',
body
):
hs_expr = extract_hs_expr(m.group(2))
# Find corresponding .toBe()
rest = body[m.end():]
be_match = re.search(r'\.toBe\(([^)]+)\)', rest)
if be_match:
expected_sx = js_val_to_sx(be_match.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)
# ── Live gallery pages ────────────────────────────────────────────
PAGE_HEADER = (
';; AUTO-GENERATED from spec/tests/hyperscript-upstream-tests.json\n'
';; DO NOT EDIT — regenerate with:\n'
';; python3 tests/playwright/generate-sx-tests.py --emit-pages\n'
)
# Actions/checks that we can't yet compile into a runner body emit a placeholder
# runner that throws; the card still renders so users can see the source. This
# keeps gallery coverage 1:1 with the JSON source of truth.
NOT_DEMONSTRABLE = '(error "not yet runnable in gallery — see test suite")'
def emit_runner_body(test, elements, var_names):
"""Emit the body of the runner lambda that runs inside a sandbox element.
Returns an SX expression string or None if the test can't be reproduced
(no HTML, unparseable action, etc.)."""
if not elements:
return None
ref = make_ref_fn(elements, var_names)
actions = parse_action(test.get('action', ''), ref)
checks_parsed = parse_checks(test.get('check', ''))
# Skip-only action list (no real action) → nothing to demonstrate
real_actions = [a for a in actions if not a.startswith(';;')]
if not real_actions:
return None
lines = []
bindings = ' '.join(
f'({var_names[i]} (dom-create-element "{el["tag"]}"))'
for i, el in enumerate(elements)
)
lines.append(f'(fn (sandbox)')
lines.append(f' (let ({bindings})')
emit_element_setup(lines, elements, var_names, root='sandbox', indent=' ')
for a in actions:
lines.append(f' {a}')
for c in checks_parsed:
sx = check_to_sx(c, ref)
lines.append(f' {sx}')
lines.append(' ))')
return '\n'.join(lines)
def emit_card(test):
"""Return an SX (~hyperscript/hs-test-card ...) call for one test."""
name_sx = sx_str(test['name'])
html_sx = sx_str(test.get('html', '') or '')
action_sx = sx_str(test.get('action', '') or '')
check_sx = sx_str(test.get('check', '') or '')
elements = parse_html(test.get('html', ''))
var_names = assign_var_names(elements) if elements else []
runner = emit_runner_body(test, elements, var_names)
if runner is None:
runner = f'(fn (sandbox) {NOT_DEMONSTRABLE})'
# :run-src is SX SOURCE TEXT — a string the island parses + evals at Run
# time. Ordinary lambda kwargs (and even bare quoted `(fn ...)` lists)
# end up lambda-ified by the prop pipeline and print as "<lambda>"
# through aser, which can't round-trip. Strings do.
run_src = sx_str(runner)
return (
f'(~hyperscript/hs-test-card\n'
f' :name {name_sx}\n'
f' :html {html_sx}\n'
f' :action {action_sx}\n'
f' :check {check_sx}\n'
f' :run-src {run_src})'
)
def emit_category_page(theme, category, tests):
"""Return SX source for one category page (all tests in that category)."""
total = len(tests)
runnable = sum(
1 for t in tests
if parse_html(t.get('html', '')) and
any(not a.startswith(';;') for a in
parse_action(t.get('action', ''),
make_ref_fn(parse_html(t.get('html', '')),
assign_var_names(parse_html(t.get('html', ''))))))
)
cards = '\n'.join(emit_card(t) for t in tests)
title = f'Hyperscript: {category} ({total} tests — {runnable} runnable)'
intro = (
f'Live cards for the upstream {category} tests. '
f'{runnable} of {total} are reproducible in-browser; '
f'the remainder show their source for reference.'
)
return (
PAGE_HEADER + '\n'
f'(defcomp ()\n'
f' (~docs/page :title {sx_str(title)}\n'
f' (p :style "color:#57534e;margin-bottom:1rem" {sx_str(intro)})\n'
f' (p :style "color:#78716c;font-size:0.875rem;margin-bottom:1rem"\n'
f' "Theme: " (a :href {sx_str(page_url([theme]))}\n'
f' :style "color:#7c3aed" {sx_str(theme)}))\n'
f' (div :style "display:flex;flex-direction:column"\n'
f' {cards})))\n'
)
def emit_theme_index(theme, cats_in_theme, cats_to_tests):
"""Return SX source for a theme index page (list of its categories)."""
total = sum(len(cats_to_tests.get(c, [])) for c in cats_in_theme)
links = []
for cat in cats_in_theme:
if cat not in cats_to_tests:
continue
n = len(cats_to_tests[cat])
href = page_url([theme, cat])
links.append(
f' (li :style "margin-bottom:0.25rem"\n'
f' (a :href {sx_str(href)} :style "color:#7c3aed;text-decoration:underline"\n'
f' {sx_str(cat)})\n'
f' (span :style "color:#78716c;margin-left:0.5rem;font-size:0.875rem"\n'
f' {sx_str(f"({n} tests)")}))'
)
title = f'Hyperscript tests: {theme} ({total} tests)'
return (
PAGE_HEADER + '\n'
f'(defcomp ()\n'
f' (~docs/page :title {sx_str(title)}\n'
f' (p :style "color:#57534e;margin-bottom:1rem"\n'
f' "Pick a category to see its live test cards.")\n'
f' (ul :style "list-style:disc;padding-left:1.5rem"\n'
+ '\n'.join(links) + '\n'
f' )))\n'
)
def emit_top_index(themes_with_counts):
"""Return SX source for the top-level /tests index page."""
links = []
for theme, count in themes_with_counts:
href = page_url([theme])
links.append(
f' (li :style "margin-bottom:0.25rem"\n'
f' (a :href {sx_str(href)} :style "color:#7c3aed;text-decoration:underline;font-weight:500"\n'
f' {sx_str(theme)})\n'
f' (span :style "color:#78716c;margin-left:0.5rem;font-size:0.875rem"\n'
f' {sx_str(f"({count} tests)")}))'
)
grand_total = sum(c for _, c in themes_with_counts)
title = f'Hyperscript test gallery ({grand_total} tests)'
return (
PAGE_HEADER + '\n'
f'(defcomp ()\n'
f' (~docs/page :title {sx_str(title)}\n'
f' (p :style "color:#57534e;margin-bottom:1rem"\n'
f' "Live cards for every upstream _hyperscript behavioural test. "\n'
f' "Each card renders the HTML into a sandbox, activates the hyperscript, "\n'
f' "dispatches the action, and runs the assertion. Pass/fail is shown "\n'
f' "with the same runtime path as the SX test suite.")\n'
f' (ul :style "list-style:disc;padding-left:1.5rem"\n'
+ '\n'.join(links) + '\n'
f' )))\n'
)
def write_page_files(categories):
"""Write gallery files. Everything is flat in applications/hyperscript/ —
gallery.sx (top), gallery-<theme>.sx, gallery-<theme>-<cat>.sx —
because the /sx/ router only dispatches one level per page-fn call."""
# Bucket categories by theme
themed = OrderedDict() # theme -> [(cat, tests)]
for cat, tests in categories.items():
theme = theme_for_category(cat)
themed.setdefault(theme, []).append((cat, tests))
# Remove any previous gallery-*.sx files so stale themes don't linger
if os.path.isdir(PAGES_DIR):
for fname in os.listdir(PAGES_DIR):
if fname == f'{GALLERY_SLUG}.sx' or fname.startswith(f'{GALLERY_SLUG}-'):
try: os.remove(os.path.join(PAGES_DIR, fname))
except OSError: pass
themes_with_counts = []
written = []
for theme, cat_pairs in themed.items():
cats_in_theme = [c for c, _ in cat_pairs]
cats_to_tests = {c: ts for c, ts in cat_pairs}
for cat, tests in cat_pairs:
fname = f'{page_slug([theme, cat])}.sx'
with open(os.path.join(PAGES_DIR, fname), 'w') as f:
f.write(emit_category_page(theme, cat, tests))
written.append(fname)
fname = f'{page_slug([theme])}.sx'
with open(os.path.join(PAGES_DIR, fname), 'w') as f:
f.write(emit_theme_index(theme, cats_in_theme, cats_to_tests))
written.append(fname)
themes_with_counts.append((theme, sum(len(ts) for _, ts in cat_pairs)))
fname = f'{GALLERY_SLUG}.sx'
with open(os.path.join(PAGES_DIR, fname), 'w') as f:
f.write(emit_top_index(themes_with_counts))
written.append(fname)
return themed, written
# ── 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('')
output.append(';; Evaluate a hyperscript expression and return its result.')
output.append(';; Compiles the expression, wraps in a thunk, evaluates, returns result.')
output.append('(define eval-hs')
output.append(' (fn (src)')
output.append(' (let ((sx (hs-to-sx (hs-compile src))))')
output.append(' (let ((handler (eval-expr-cek')
output.append(' (list (quote fn) (list (quote me)) (list (quote let) (list (list (quote it) nil) (list (quote event) nil)) sx)))))')
output.append(' (handler nil)))))')
output.append('')
output.append(';; Evaluate with a specific me value (for "I am between" etc.)')
output.append('(define eval-hs-with-me')
output.append(' (fn (src me-val)')
output.append(' (let ((sx (hs-to-sx (hs-compile src))))')
output.append(' (let ((handler (eval-expr-cek')
output.append(' (list (quote fn) (list (quote me)) (list (quote let) (list (list (quote it) nil) (list (quote event) nil)) sx)))))')
output.append(' (handler me-val)))))')
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}')
# ── Optional: live gallery pages ──────────────────────────────────
import sys
if '--emit-pages' in sys.argv:
themed, written = write_page_files(categories)
print(f'\nGallery pages written under {PAGES_DIR} ({len(written)} files)')
for theme, pairs in themed.items():
cats = ', '.join(c for c, _ in pairs)
total_t = sum(len(ts) for _, ts in pairs)
print(f' {theme} ({total_t} tests, {len(pairs)} categories): {cats}')