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:
@@ -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}')
|
||||
|
||||
Reference in New Issue
Block a user