HS parser/compiler/mock: fix 31 test failures across 7 issues

Parser:
- Relax (number? v) to v in parse-one-transition so (expr)unit works
- Add (match-kw "then") before parse-cmd-list in parse-for-cmd
- Handle "indexed by" syntax alongside "index" in for loops
- Add "indexed" to hs-keywords to prevent unit-suffix consumption

Compiler:
- Use map-indexed instead of for-each for indexed for-loops

Test generator:
- Preserve \" escapes in process_hs_val via placeholder/restore

Mock DOM:
- Coerce insertAdjacentHTML values via dom_stringify (match browser)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 20:46:01 +00:00
parent 3ba819d9ae
commit be84246961
6 changed files with 379 additions and 76 deletions

View File

@@ -19,6 +19,57 @@ 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)
@@ -530,9 +581,12 @@ def parse_dev_body(body, elements, var_names):
def process_hs_val(hs_val):
"""Process a raw HS attribute value: collapse whitespace, insert 'then' separators."""
# Convert escaped newlines/tabs to real whitespace before stripping backslashes
# 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)
@@ -545,12 +599,16 @@ def process_hs_val(hs_val):
return hs_val.strip()
def emit_element_setup(lines, elements, var_names):
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 parents to body)
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
@@ -559,41 +617,41 @@ def emit_element_setup(lines, elements, var_names):
for i, el in enumerate(elements):
var = var_names[i]
if el['id']:
lines.append(f' (dom-set-attr {var} "id" "{el["id"]}")')
lines.append(f'{indent}(dom-set-attr {var} "id" "{el["id"]}")')
for cls in el['classes']:
lines.append(f' (dom-add-class {var} "{cls}")')
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' ;; HS source has bare quotes or embedded HTML')
lines.append(f'{indent};; HS source has bare quotes or embedded HTML')
else:
hs_escaped = hs_val.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f' (dom-set-attr {var} "_" "{hs_escaped}")')
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' ;; SKIP attr {aname} (contains special chars)')
lines.append(f'{indent};; SKIP attr {aname} (contains special chars)')
continue
aval_escaped = aval.replace('"', '\\"')
lines.append(f' (dom-set-attr {var} "{aname}" "{aval_escaped}")')
lines.append(f'{indent}(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}")')
lines.append(f'{indent}(dom-set-inner-html {var} "{inner_escaped}")')
# Phase 2: Append elements (children to parents, roots to body)
# 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' (dom-append {parent_var} {var})')
lines.append(f'{indent}(dom-append {parent_var} {var})')
else:
lines.append(f' (dom-append (dom-body) {var})')
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' (hs-activate! {var_names[i]})')
lines.append(f'{indent}(hs-activate! {var_names[i]})')
def generate_test_chai(test, elements, var_names, idx):
@@ -905,6 +963,215 @@ def generate_test(test, idx):
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 = []
@@ -989,3 +1256,15 @@ 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}')