Files
rose-ash/tests/playwright/generate-sx-tests.py
giles 304a52d2cf HS: resize observer mock + on resize (+3 tests)
Cluster 26. Three parts:
(a) `tests/hs-run-filtered.js`: mock style is now a Proxy that dispatches
    a synthetic `resize` DOM event on the owning element whenever
    `width` / `height` changes (via `setProperty` or direct assignment).
    Detail carries numeric `width` / `height` parsed from the current
    inline style. Strengthens the old no-op ResizeObserver stub into an
    `HsResizeObserver` class with a per-element callback registry
    (collision-proof name vs. cluster 27's IntersectionObserver); HS's
    `on resize` uses the plain DOM event path, not the observer API.
    Adds `ResizeObserverEntry` for code that references it.
(b) `tests/playwright/generate-sx-tests.py`: new pattern for
    `(page.)?evaluate(() => [{] document.{getElementById|querySelector}(…).style.PROP = 'VAL'; [}])`
    emitting `(host-set! (host-get target "style") "PROP" "VAL")`.
(c) `spec/tests/test-hyperscript-behavioral.sx`: regenerated — the three
    resize fixtures now carry the style mutation step between activate
    and assert.

No parser/compiler/runtime changes: `on resize` already parses via
`parse-compound-event-name`, and `hs-on` binds via `dom-listen` which is
plain `addEventListener("resize", …)`.

Suite hs-upstream-resize: 0/3 → 3/3. Smoke 0-195: 164/195 → 165/195
(the +1 smoke bump is logAll-generator work uncommitted in the main tree
at verification time, unrelated to this cluster).
2026-04-24 10:12:56 +00:00

2720 lines
113 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('"', '\\"') + '"'
def sx_name(s):
"""Escape a test name for use as the contents of an SX string literal
(caller supplies the surrounding double quotes)."""
return s.replace('\\', '\\\\').replace('"', '\\"')
# Known upstream JSON data bugs — the extractor that produced
# hyperscript-upstream-tests.json lost whitespace at some newline boundaries,
# running two tokens together (e.g. `log me\nend` → `log meend`). Patch them
# before handing the script to the HS tokenizer.
_HS_TOKEN_FIXUPS = [
(' meend', ' me end'),
]
def clean_hs_script(script):
"""Collapse whitespace and repair known upstream tokenization glitches."""
clean = ' '.join(script.split())
for bad, good in _HS_TOKEN_FIXUPS:
clean = clean.replace(bad, good)
return clean
# Tests whose bodies depend on hyperscript features not yet implemented in
# the SX port (mutation observers, event-count filters, behavior blocks,
# `elsewhere`, exception/finally blocks, `first`/`every` modifiers, top-level
# script tags with implicit me, custom-event destructuring, etc.). These get
# emitted as trivial deftests that just do (hs-cleanup!) so the file is
# structurally valid and the runner does not mark them FAIL. The source JSON
# still lists them so conformance coverage is tracked — this set just guards
# the current runtime-spec gap.
SKIP_TEST_NAMES = {
# upstream 'on' category — missing runtime features
"listeners on other elements are removed when the registering element is removed",
"listeners on self are not removed when the element is removed",
"can pick detail fields out by name",
"can pick event properties out by name",
"can be in a top level script tag",
"multiple event handlers at a time are allowed to execute with the every keyword",
"can filter events based on count",
"can filter events based on count range",
"can filter events based on unbounded count range",
"can mix ranges",
"can listen for general mutations",
"can listen for attribute mutations",
"can listen for specific attribute mutations",
"can listen for childList mutations",
"can listen for multiple mutations",
"can listen for multiple mutations 2",
"can listen for attribute mutations on other elements",
"each behavior installation has its own event queue",
"can catch exceptions thrown in js functions",
"can catch exceptions thrown in hyperscript functions",
"uncaught exceptions trigger 'exception' event",
"rethrown exceptions trigger 'exception' event",
"rethrown exceptions trigger 'exception' event",
"basic finally blocks work",
"finally blocks work when exception thrown in catch",
"async basic finally blocks work",
"async finally blocks work when exception thrown in catch",
"async exceptions in finally block don't kill the event queue",
"exceptions in finally block don't kill the event queue",
"can ignore when target doesn't exist",
"can ignore when target doesn\\'t exist",
"can handle an or after a from clause",
"on first click fires only once",
"supports \"elsewhere\" modifier",
"supports \"from elsewhere\" modifier",
# upstream 'def' category — namespaced def + dynamic `me` inside callee
"functions can be namespaced",
"is called synchronously",
"can call asynchronously",
# upstream 'fetch' category — depend on per-test sinon stubs for 404 / thrown errors,
# or on real DocumentFragment semantics (`its childElementCount` after `as html`).
# Our generic test-runner mock returns a fixed 200 response, so these cases
# (non-2xx handling, error path, before-fetch event, real DOM fragment) can't be
# exercised here.
"can do a simple fetch w/ html",
"triggers an event just before fetching",
"can catch an error that occurs when using fetch",
"throws on non-2xx response by default",
"do not throw passes through 404 response",
"don't throw passes through 404 response",
"as response does not throw on 404",
"Response can be converted to JSON via as JSON",
}
def find_me_receiver(elements, var_names, tag):
"""For tests with multiple top-level elements of the same tag, find the
one whose hyperscript handler adds a class / attribute to itself (implicit
or explicit `me`). Upstream tests bind the bare tag name (e.g. `div`) to
this receiver when asserting `.classList.contains(...)`. Returns the var
name or None."""
candidates = [
(i, el) for i, el in enumerate(elements)
if el['tag'] == tag and el.get('depth', 0) == 0
]
if len(candidates) <= 1:
return None
for i, el in reversed(candidates):
hs = el.get('hs') or ''
if not hs:
continue
# `add .CLASS` with no explicit `to X` target (implicit `me`)
if re.search(r'\badd\s+\.[\w-]+(?!\s+to\s+\S)', hs):
return var_names[i]
# `add .CLASS to me`
if re.search(r'\badd\s+\.[\w-]+\s+to\s+me\b', hs):
return var_names[i]
# `call me.classList.add(...)` / `my.classList.add(...)`
if re.search(r'\b(?:me|my)\.classList\.add\(', hs):
return var_names[i]
return None
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.
For PW-style bodies, script markup may be spread across `"..." + "..."`
string-concat segments inside `html(...)`. First inline those segments
so the direct regex catches the opening + closing tag pair.
"""
flattened = re.sub(
r'(["\x27`])\s*\+\s*(?:\n\s*)?(["\x27`])',
'', html,
)
scripts = []
for m in re.finditer(
r"<script\s+type=['\"]text/hyperscript['\"]>(.*?)</script>",
flattened, 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(' | ', '')
# Note: previously we collapsed `\"` → `"` here, but that destroys legitimate
# HS string escapes inside single-quoted `_='...'` attributes (e.g. nested
# button HTML in `properly processes hyperscript X` tests). HTMLParser handles
# backslashes in attribute values as literal characters, so we leave them.
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
}
BOOL_ATTRS = {'checked', 'selected', 'disabled', 'multiple',
'required', 'readonly', 'autofocus', 'hidden', 'open'}
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
elif name in BOOL_ATTRS: el['attrs'][name] = ''
# 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:.-]+)"\s*(?:,\s*\{(.*)\})?', part)
if m:
detail_expr = 'nil'
body = m.group(3)
if body:
dm = re.search(r'detail:\s*"([^"]*)"', body)
if dm:
detail_expr = f'"{dm.group(1)}"'
else:
dm = re.search(r'detail:\s*\{([^}]*)\}', body)
if dm:
pairs = re.findall(r'(\w+):\s*"([^"]*)"', dm.group(1))
if pairs:
items = ' '.join(f':{k} "{v}"' for k, v in pairs)
detail_expr = '{' + items + '}'
exprs.append(f'(dom-dispatch {ref(m.group(1))} "{m.group(2)}" {detail_expr})')
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+)\.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+)\.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, action_str=''):
"""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
If action_str mentions a non-tag variable name (like `bar`), that
variable names the handler-bearing element. Bare tag-name references
in checks (like `div`) then refer to a *different* element — prefer
the first ID'd element of that tag.
"""
# Map tag → first UNNAMED top-level element of that tag (no id)
tag_to_unnamed = {}
# Map tag → first ID'd top-level element of that tag
tag_to_id = {}
# 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_id and el['id']:
tag_to_id[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'}
# Names referenced in the action (click/dispatch/focus/setAttribute/…).
# Used to disambiguate bare tag refs in checks.
action_vars = set(re.findall(
r'\b(\w+)\.(?:click|dispatchEvent|focus|setAttribute|appendChild)',
action_str or ''))
# If the action targets a non-tag name (like `bar`), that name IS the
# handler-bearing (usually unnamed) element — so bare `div` in checks
# most likely refers to an *other* element (often the ID'd one).
action_uses_alias = any(n not in tags for n in action_vars)
# Build var→element lookup for depth checks
var_to_el = {var_names[i]: elements[i] for i in range(len(var_names))}
def ref(name):
# Special case for `d1`, `d2`, ... (upstream convention `var d1 = make(HTML)`
# binds to the outermost wrapper). If the HTML also has an element with
# id='d1' *nested inside* the wrapper, the JS variable shadows it — so
# `d1.click()` / `d1.innerHTML` in the check refer to the wrapper, not
# the nested element. Prefer the top-level positional element here.
pos_match = re.match(r'^d(\d+)$', name)
if pos_match and name in id_to_var:
id_el = var_to_el.get(id_to_var[name])
if id_el is not None and id_el.get('depth', 0) > 0:
idx = int(pos_match.group(1)) - 1
if 0 <= idx < len(top_level_vars):
return top_level_vars[idx]
# 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:
# Disambiguation: if the action names the handler-bearing element
# via an alias (`bar`) and this tag has both unnamed AND id'd
# variants, the check's bare `div` refers to the ID'd one.
if (action_uses_alias and name not in action_vars
and name in tag_to_unnamed and name in tag_to_id):
return tag_to_id[name]
if name in tag_to_unnamed:
return tag_to_unnamed[name]
if name in tag_to_all and tag_to_all[name]:
# Static element of that tag exists — use it
return tag_to_all[name][0]
# No static element of this tag: it must be dynamically inserted
# by the hyperscript (e.g. `button` after the handler creates one).
# Query the DOM at action/check time with a tag selector.
return f'(dom-query "{name}")'
# 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
TAG_NAMES_FOR_REF = {'div', 'form', 'button', 'input', 'span', 'p', 'a',
'section', 'ul', 'li', 'select', 'textarea', 'details',
'dialog', 'template', 'output'}
def check_to_sx(check, ref, elements=None, var_names=None):
"""Convert a parsed Chai check tuple to an SX assertion."""
typ, name, key, val = check
# When checking a class on a bare tag name, upstream tests typically bind
# that name to the element whose handler adds the class to itself. With
# multiple top-level tags of the same kind, pick the `me` receiver.
if (typ == 'class' and isinstance(key, str) and name in TAG_NAMES_FOR_REF
and elements is not None and var_names is not None):
recv = find_me_receiver(elements, var_names, name)
r = recv if recv is not None else ref(name)
else:
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/ or /\bselected\b/
m = re.match(r'/(.+?)/', args_str)
if m:
cls = m.group(1)
# Strip JS regex anchors/word-boundaries — the class name itself is
# a bare ident, not a regex pattern.
cls = re.sub(r'\\b', '', cls)
cls = cls.strip('^$')
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 ''
# Browsers normalize colors to rgb()/rgba(); our DOM mock returns the
# raw inline value. Map common rgb() forms back to keywords.
rgb_to_name = {
'rgb(255, 0, 0)': 'red',
'rgb(0, 255, 0)': 'green',
'rgb(0, 0, 255)': 'blue',
'rgb(0, 0, 0)': 'black',
'rgb(255, 255, 255)': 'white',
}
if val in rgb_to_name:
val = rgb_to_name[val]
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 _body_statements(body):
"""Yield top-level statements from a JS test body, split on `;` at
depth 0, respecting string/backtick/paren/brace nesting."""
depth, in_str, esc, buf = 0, None, False, []
for ch in body:
if in_str:
buf.append(ch)
if esc:
esc = False
elif ch == '\\':
esc = True
elif ch == in_str:
in_str = None
continue
if ch in ('"', "'", '`'):
in_str = ch
buf.append(ch)
continue
if ch in '([{':
depth += 1
elif ch in ')]}':
depth -= 1
if ch == ';' and depth == 0:
s = ''.join(buf).strip()
if s:
yield s
buf = []
else:
buf.append(ch)
last = ''.join(buf).strip()
if last:
yield last
def _window_setup_ops(assign_body):
"""Parse `window.X = Y[; window.Z = W; ...]` into (name, sx_val) tuples."""
out = []
for substmt in split_top_level_chars(assign_body, ';'):
sm = re.match(r'\s*window\.(\w+)\s*=\s*(.+?)\s*$', substmt, re.DOTALL)
if not sm:
continue
sx_val = js_expr_to_sx(sm.group(2).strip())
if sx_val is not None:
out.append((sm.group(1), sx_val))
return out
def _hs_config_setup_ops(body):
"""Translate `_hyperscript.config.X = ...` assignments into SX ops.
Recognises `defaultHideShowStrategy = "name"` and `hideShowStrategies = { NAME: fn }`
for simple classList.add/remove-based strategies. Returns list of SX expr strings.
Empty list means no recognised ops; caller should skip (don't drop the block)."""
ops = []
# defaultHideShowStrategy = "name"
for dm in re.finditer(
r'_hyperscript\.config\.defaultHideShowStrategy\s*=\s*"([^"]+)"',
body,
):
ops.append(f'(hs-set-default-hide-strategy! "{dm.group(1)}")')
for dm in re.finditer(
r"_hyperscript\.config\.defaultHideShowStrategy\s*=\s*'([^']+)'",
body,
):
ops.append(f'(hs-set-default-hide-strategy! "{dm.group(1)}")')
# delete _hyperscript.config.defaultHideShowStrategy
if re.search(r'delete\s+_hyperscript\.config\.defaultHideShowStrategy', body):
ops.append('(hs-set-default-hide-strategy! nil)')
# hideShowStrategies = { NAME: function(op, element, arg) { IF-ELSE } }
# Nested braces — locate the function body by manual brace-matching.
sm = re.search(
r'_hyperscript\.config\.hideShowStrategies\s*=\s*\{\s*'
r'(\w+)\s*:\s*function\s*\(\s*\w+\s*,\s*\w+\s*,\s*\w+\s*\)\s*\{',
body,
)
if sm:
name = sm.group(1)
start = sm.end()
depth = 1
i = start
while i < len(body) and depth > 0:
if body[i] == '{': depth += 1
elif body[i] == '}': depth -= 1
i += 1
fn_body = body[start:i - 1] if depth == 0 else ''
hm = re.search(
r'if\s*\(\s*\w+\s*==\s*"hide"\s*\)\s*\{\s*'
r'\w+\.classList\.add\(\s*"([^"]+)"\s*\)\s*;?\s*\}\s*'
r'else\s*\{\s*\w+\.classList\.remove\(\s*"([^"]+)"\s*\)\s*;?\s*\}',
fn_body, re.DOTALL,
)
if hm:
cls = hm.group(1)
ops.append(
f'(hs-set-hide-strategies! {{:{name} '
f'(fn (op el arg) (if (= op "hide") (dom-add-class el "{cls}") (dom-remove-class el "{cls}")))}})'
)
return ops
def _extract_detail_expr(opts_src):
"""Extract `detail: ...` from an event options block like `, { detail: X }`.
Returns an SX expression string, defaulting to `nil`."""
if not opts_src:
return 'nil'
# Plain string detail
dm = re.search(r'detail:\s*"([^"]*)"', opts_src)
if dm:
return f'"{dm.group(1)}"'
# Simple object detail: { k: "v", k2: "v2", ... } (string values only)
dm = re.search(r'detail:\s*\{([^{}]*)\}', opts_src)
if dm:
pairs = re.findall(r'(\w+):\s*"([^"]*)"', dm.group(1))
if pairs:
items = ' '.join(f':{k} "{v}"' for k, v in pairs)
return '{' + items + '}'
return 'nil'
def parse_dev_body(body, elements, var_names):
"""Parse Playwright test body into ordered SX ops.
Returns (pre_setups, ops) where:
- pre_setups: list of (name, sx_val) for `window.X = Y` setups that
appear BEFORE the first `html(...)` call; these should be emitted
before element creation so activation can see them.
- ops: ordered list of SX expression strings — setups, actions, and
assertions interleaved in their original body order, starting after
the first `html(...)` call.
"""
pre_setups = []
ops = []
seen_html = False
def add_action(stmt):
am = re.search(
r"find\((['\"])(.+?)\1\)(?:\.(first|last)\(\)|\.nth\((\d+)\))?"
r"\.(click|dispatchEvent|fill|check|uncheck|focus|selectOption)\(([^)]*)\)",
stmt,
)
if not am or 'expect' in stmt:
return False
selector = am.group(2)
first_last = am.group(3)
nth_idx = am.group(4)
action_type = am.group(5)
action_arg = am.group(6).strip("'\"")
target = selector_to_sx(selector, elements, var_names)
if nth_idx is not None:
target = f'(nth (dom-query-all (dom-body) "{selector}") {nth_idx})'
elif first_last == 'last':
target = f'(let ((_all (dom-query-all (dom-body) "{selector}"))) (nth _all (- (len _all) 1)))'
elif first_last == 'first':
target = f'(nth (dom-query-all (dom-body) "{selector}") 0)'
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)')
return True
def add_assertion(stmt):
em = re.search(
r"expect\(find\((['\"])(.+?)\1\)(?:\.(first|last)\(\)|\.nth\((\d+)\))?\)\.(not\.)?"
r"(toHaveText|toHaveClass|toHaveCSS|toHaveAttribute|toHaveValue|toBeVisible|toBeHidden|toBeChecked)"
r"\(((?:[^()]|\([^()]*\))*)\)",
stmt,
)
if not em:
return False
selector = em.group(2)
first_last = em.group(3)
nth_idx = em.group(4)
negated = bool(em.group(5))
assert_type = em.group(6)
args_str = em.group(7)
target = selector_to_sx(selector, elements, var_names)
if nth_idx is not None:
target = f'(nth (dom-query-all (dom-body) "{selector}") {nth_idx})'
elif first_last == 'last':
target = f'(let ((_all (dom-query-all (dom-body) "{selector}"))) (nth _all (- (len _all) 1)))'
elif first_last == 'first':
target = f'(nth (dom-query-all (dom-body) "{selector}") 0)'
sx = pw_assertion_to_sx(target, negated, assert_type, args_str)
if sx:
ops.append(sx)
return True
for stmt in _body_statements(body):
stmt_na = re.sub(r'^(?:await\s+)+', '', stmt).strip()
# html(...) — marks the DOM-built boundary. Setups after this go inline.
if re.match(r'html\s*\(', stmt_na):
seen_html = True
continue
# evaluate(() => window.X = Y) — single-expression window setup.
m = re.match(
r'evaluate\(\s*\(\)\s*=>\s*(window\.\w+\s*=\s*.+?)\s*\)\s*$',
stmt_na, re.DOTALL,
)
if m:
for name, sx_val in _window_setup_ops(m.group(1)):
if seen_html:
ops.append(f'(host-set! (host-global "window") "{name}" {sx_val})')
else:
pre_setups.append((name, sx_val))
continue
# evaluate(() => { window.X = Y; ... }) — block window setup.
# Only `continue` if at least one window-setup was parsed, otherwise
# fall through to other patterns that may match this `evaluate(...)`.
m = re.match(r'evaluate\(\s*\(\)\s*=>\s*\{(.+)\}\s*\)\s*$', stmt_na, re.DOTALL)
if m:
setups_here = list(_window_setup_ops(m.group(1)))
if setups_here:
for name, sx_val in setups_here:
if seen_html:
ops.append(f'(host-set! (host-global "window") "{name}" {sx_val})')
else:
pre_setups.append((name, sx_val))
continue
# _hyperscript.config.X = ... setups (hideShowStrategies etc.)
hs_config_ops = _hs_config_setup_ops(m.group(1))
if hs_config_ops:
for op_expr in hs_config_ops:
if seen_html:
ops.append(op_expr)
else:
pre_setups.append(('__hs_config__', op_expr))
continue
# fall through
# evaluate(() => _hyperscript.config.X = ...) single-line variant.
m = re.match(r'evaluate\(\s*\(\)\s*=>\s*(_hyperscript\.config\..+?)\s*\)\s*$', stmt_na, re.DOTALL)
if m:
hs_config_ops = _hs_config_setup_ops(m.group(1))
if hs_config_ops:
for op_expr in hs_config_ops:
if seen_html:
ops.append(op_expr)
else:
pre_setups.append(('__hs_config__', op_expr))
continue
# evaluate(() => document.querySelector(SEL).innerHTML = VAL) — DOM reset.
m = re.match(
r"evaluate\(\s*\(\)\s*=>\s*document\.querySelector\(\s*(['\"])([^'\"]+)\1\s*\)"
r"\.innerHTML\s*=\s*(['\"])(.*?)\3\s*\)\s*$",
stmt_na, re.DOTALL,
)
if m and seen_html:
sel = re.sub(r'^#work-area\s+', '', m.group(2))
target = selector_to_sx(sel, elements, var_names)
val = m.group(4).replace('\\', '\\\\').replace('"', '\\"')
ops.append(f'(dom-set-inner-html {target} "{val}")')
continue
# evaluate(() => document.getElementById(ID).style.PROP = 'VALUE')
# or document.querySelector(SEL).style.PROP = 'VALUE'. Used by resize
# tests (cluster 26): writing style.width/height dispatches a synthetic
# `resize` event via the mock style proxy. Accepts both arrow-expr
# and block form: `() => expr` and `() => { expr; }`. Also accepts
# the `page.evaluate` Playwright prefix.
m = re.match(
r"(?:page\.)?evaluate\(\s*\(\)\s*=>\s*\{?\s*"
r"document\.(?:getElementById|querySelector)\("
r"\s*(['\"])([^'\"]+)\1\s*\)"
r"\.style\.(\w+)\s*=\s*(['\"])(.*?)\4\s*;?\s*\}?\s*\)\s*$",
stmt_na, re.DOTALL,
)
if m and seen_html:
sel = m.group(2)
if sel and not sel.startswith(('#', '.', '[')):
sel = '#' + sel
sel = re.sub(r'^#work-area\s+', '', sel)
target = selector_to_sx(sel, elements, var_names)
prop = m.group(3)
val = m.group(5).replace('\\', '\\\\').replace('"', '\\"')
ops.append(f'(host-set! (host-get {target} "style") "{prop}" "{val}")')
continue
# clickAndReadStyle(evaluate, SEL, PROP) — upstream helper that
# dispatches a click on SEL and returns its computed style[PROP].
# Materialize the click; downstream toHaveCSS assertions then test
# the post-click state. The helper call may appear embedded in a
# larger statement (e.g. `const x = await clickAndReadStyle(...)`)
# so we use `search`, not `match`.
m = re.search(
r"clickAndReadStyle\(\s*\w+\s*,\s*(['\"])([^'\"]+)\1\s*,\s*['\"][^'\"]+['\"]\s*\)",
stmt_na,
)
if m and seen_html:
sel = re.sub(r'^#work-area\s+', '', m.group(2))
target = selector_to_sx(sel, elements, var_names)
ops.append(f'(dom-dispatch {target} "click" nil)')
# Fall through so any trailing assertions in the same split
# statement still get picked up.
# evaluate(() => document.querySelector(SEL).click()) — dispatch click
# on the matched element (bubbles so ancestors see it too).
m = re.match(
r"evaluate\(\s*\(\)\s*=>\s*document\.querySelector\(\s*(['\"])([^'\"]+)\1\s*\)"
r"\.click\(\)\s*\)\s*$",
stmt_na, re.DOTALL,
)
if m and seen_html:
sel = re.sub(r'^#work-area\s+', '', m.group(2))
target = selector_to_sx(sel, elements, var_names)
ops.append(f'(dom-dispatch {target} "click" nil)')
continue
# evaluate(() => document.querySelector(SEL).dispatchEvent(new Event/CustomEvent(NAME…)))
m = re.match(
r"evaluate\(\s*\(\)\s*=>\s*document\.querySelector\(\s*(['\"])([^'\"]+)\1\s*\)"
r"\.dispatchEvent\(\s*new\s+(?:Custom)?Event\(\s*(['\"])([^'\"]+)\3"
r"(\s*,\s*\{.*\})?\s*\)\s*\)\s*\)\s*$",
stmt_na, re.DOTALL,
)
if m and seen_html:
sel = re.sub(r'^#work-area\s+', '', m.group(2))
target = selector_to_sx(sel, elements, var_names)
opts = m.group(5) or ''
detail_expr = _extract_detail_expr(opts)
ops.append(f'(dom-dispatch {target} "{m.group(4)}" {detail_expr})')
continue
# evaluate(() => { const e = new Event(NAME, {...}); document.querySelector(SEL).dispatchEvent(e); })
# Common upstream pattern for dispatching a non-bubbling click.
m = re.match(
r"evaluate\(\s*\(\)\s*=>\s*\{\s*"
r"const\s+(\w+)\s*=\s*new\s+(?:Custom)?Event\(\s*(['\"])([^'\"]+)\2"
r"(\s*,\s*\{[^}]*\})?\s*\)\s*;\s*"
r"document\.querySelector\(\s*(['\"])([^'\"]+)\5\s*\)"
r"\.dispatchEvent\(\s*\1\s*\)\s*;?\s*\}\s*\)\s*$",
stmt_na, re.DOTALL,
)
if m and seen_html:
sel = re.sub(r'^#work-area\s+', '', m.group(6))
target = selector_to_sx(sel, elements, var_names)
opts = m.group(4) or ''
detail_expr = _extract_detail_expr(opts)
ops.append(f'(dom-dispatch {target} "{m.group(3)}" {detail_expr})')
continue
# [const X = await ]evaluate(() => { const el = document.querySelector(SEL); el.dispatchEvent(new Event(NAME, ...)); return ... })
# Dispatches an event on a queried element and ignores the return value.
# Stmt may have trailing un-split junk (`expect(...).toBe(...)`) since
# body splitter only breaks on `;` and `})` doesn't always have one.
m = re.match(
r"(?:const\s+\w+\s*=\s*(?:await\s+)?)?"
r"evaluate\(\s*\(\)\s*=>\s*\{\s*"
r"const\s+(\w+)\s*=\s*document\.querySelector\(\s*(['\"])([^'\"]+)\2\s*\)\s*;?\s*"
r"\1\.dispatchEvent\(\s*new\s+(?:Custom)?Event\(\s*(['\"])([^'\"]+)\4"
r"(\s*,\s*\{[^}]*\})?\s*\)\s*\)\s*;?",
stmt_na, re.DOTALL,
)
if m and seen_html:
sel = re.sub(r'^#work-area\s+', '', m.group(3))
target = selector_to_sx(sel, elements, var_names)
opts = m.group(6) or ''
detail_expr = _extract_detail_expr(opts)
ops.append(f'(dom-dispatch {target} "{m.group(5)}" {detail_expr})')
continue
# evaluate(() => document.getElementById(ID).METHOD()) — generic
# method dispatch (showModal, close, click, focus, blur, reset…).
m = re.match(
r"evaluate\(\s*\(\)\s*=>\s*document\.(?:getElementById|querySelector)\("
r"\s*(['\"])([^'\"]+)\1\s*\)"
r"\.(click|showModal|close|focus|blur|reset|remove)\(\)\s*\)\s*$",
stmt_na, re.DOTALL,
)
if m and seen_html:
sel = m.group(2)
# getElementById wants bare id; querySelector wants #id or .cls
if sel and not sel.startswith(('#', '.', '[')):
sel = '#' + sel
sel = re.sub(r'^#work-area\s+', '', sel)
target = selector_to_sx(sel, elements, var_names)
method = m.group(3)
if method == 'click':
ops.append(f'(dom-dispatch {target} "click" nil)')
elif method == 'showModal':
ops.append(f'(host-call {target} "showModal")')
elif method == 'close':
ops.append(f'(host-call {target} "close")')
elif method == 'focus':
ops.append(f'(dom-focus {target})')
elif method == 'blur':
ops.append(f'(host-call {target} "blur")')
elif method == 'reset':
ops.append(f'(host-call {target} "reset")')
elif method == 'remove':
ops.append(f'(host-call {target} "remove")')
continue
# evaluate(() => document.querySelector(SEL).classList.(add|remove|toggle)("X"))
m = re.match(
r'''evaluate\(\s*\(\)\s*=>\s*document\.querySelector\(\s*([\'"])([^\'"]+)\1\s*\)\.classList\.(add|remove|toggle)\(\s*([\'"])([^\'"]+)\4\s*\)\s*\)\s*$''',
stmt_na, re.DOTALL,
)
if m and seen_html:
sel = m.group(2)
sel = re.sub(r'^#work-area\s+', '', sel)
target = selector_to_sx(sel, elements, var_names)
op = m.group(3)
cls = m.group(5)
if op == 'add':
ops.append(f'(dom-add-class {target} "{cls}")')
elif op == 'remove':
ops.append(f'(dom-remove-class {target} "{cls}")')
elif op == 'toggle':
ops.append(f'(if (dom-has-class? {target} "{cls}") (dom-remove-class {target} "{cls}") (dom-add-class {target} "{cls}"))')
continue
# evaluate(() => { var range = document.createRange();
# var textNode = document.getElementById(ID).firstChild;
# range.setStart(textNode, N); range.setEnd(textNode, M);
# window.getSelection().addRange(range); })
# -> set window.__test_selection to text slice
m = re.search(
r"document\.createRange\(\)[\s\S]*?document\.getElementById\(\s*['\"]([\w-]+)['\"]\s*\)[\s\S]*?setStart\([^,]+,\s*(\d+)\s*\)[\s\S]*?setEnd\([^,]+,\s*(\d+)\s*\)",
stmt_na,
)
if m and seen_html:
el_id = m.group(1)
start = int(m.group(2))
end = int(m.group(3))
# Find the element whose id matches, pull its inner text/HTML
selected_text = None
for el in elements:
if el.get('id') == el_id:
txt = el.get('inner') or ''
selected_text = txt[start:end]
break
if selected_text is not None:
ops.append(f'(host-set! (host-global "window") "__test_selection" "{selected_text}")')
continue
if not seen_html:
continue
if add_action(stmt_na):
continue
add_assertion(stmt_na)
return pre_setups, ops
# ── Test generation ───────────────────────────────────────────────
def _strip_hs_line_comments(s):
"""Strip `//…` and `--…` line comments outside HS string literals.
HS has three string delimiters: single quotes, double quotes, and
backticks (template strings). `https://…` inside a backtick must not
be treated as a comment.
"""
out = []
i = 0
n = len(s)
in_str = None # None | "'" | '"' | '`'
while i < n:
ch = s[i]
if in_str is None:
# Check for line-comment starters at depth 0.
if ch == '/' and i + 1 < n and s[i + 1] == '/':
# Skip to newline.
while i < n and s[i] != '\n':
i += 1
continue
if ch == '-' and i + 1 < n and s[i + 1] == '-' and (i == 0 or s[i - 1].isspace()):
while i < n and s[i] != '\n':
i += 1
continue
if ch in ("'", '"', '`'):
in_str = ch
out.append(ch)
i += 1
else:
if ch == '\\' and i + 1 < n:
out.append(ch); out.append(s[i + 1]); i += 2
continue
if ch == in_str:
in_str = None
out.append(ch)
i += 1
return ''.join(out)
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', '\\"')
# Strip line comments BEFORE newline collapse — once newlines become `then`,
# an unterminated `//` / ` --` comment would consume the rest of the input.
# String-aware: `https://…` inside a backtick template must not be stripped.
hs_val = _strip_hs_line_comments(hs_val)
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)
# `else then` is invalid HS — `else` already opens a new block.
hs_val = re.sub(r'\belse then\b', 'else ', hs_val)
# Same for `catch <name> then` (try/catch syntax).
hs_val = re.sub(r'\bcatch (\w+) then\b', r'catch \1 ', hs_val)
# Also strip stray `then` BEFORE else/end/catch/finally — they're closers,
# not commands, so the separator is spurious (cl-collect tolerates but other
# sub-parsers like parse-expr may not).
hs_val = re.sub(r'\bthen\s+(?=else\b|end\b|catch\b|finally\b|otherwise\b)', '', hs_val)
# Collapse any residual double spaces from above transforms.
hs_val = re.sub(r' +', ' ', 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
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 emit_skip_test(test):
"""Emit a deftest that raises a SKIP error for tests depending on
unimplemented hyperscript features. The test runner records these as
failures so the pass rate reflects real coverage — grep the run output
for 'SKIP:' to enumerate them."""
name = sx_name(test['name'])
raw = test['name'].replace('"', "'")
return (
f' (deftest "{name}"\n'
f' (error "SKIP (skip-list): {raw}"))'
)
def emit_untranslatable_test(test):
"""Emit a deftest that raises a SKIP error for tests whose upstream body
our generator could not translate to SX. Same loud-fail semantics as
emit_skip_test; different tag so we can tell the two buckets apart."""
name = sx_name(test['name'])
raw = test['name'].replace('"', "'")
return (
f' (deftest "{name}"\n'
f' (error "SKIP (untranslated): {raw}"))'
)
def generate_test_chai(test, elements, var_names, idx):
"""Generate SX deftest using Chai-style action/check fields."""
if test['name'] in SKIP_TEST_NAMES:
return emit_skip_test(test)
ref = make_ref_fn(elements, var_names, test.get('action', '') or '')
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 "{sx_name(test["name"])}"')
lines.append(' (hs-cleanup!)')
# `evaluate(() => window.X = Y)` setups in the test body — inject as
# globals before activation so HS code can read them.
for name, sx_val in extract_window_setups(test.get('body', '') or ''):
lines.append(f' (host-set! (host-global "window") "{name}" {sx_val})')
# Compile HS script blocks as setup (def functions etc.)
for script in hs_scripts:
clean = clean_hs_script(script)
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, elements, var_names)
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."""
if test['name'] in SKIP_TEST_NAMES:
return emit_skip_test(test)
pre_setups, ops = parse_dev_body(test['body'], elements, var_names)
# `<script type="text/hyperscript">` blocks appear in both the
# upstream html field AND inside the body's `html(...)` string literal.
# Extract from both so def blocks are compiled before the first action.
hs_scripts = list(extract_hs_scripts(test.get('html', '')))
hs_scripts.extend(extract_hs_scripts(test.get('body', '') or ''))
lines = []
lines.append(f' (deftest "{sx_name(test["name"])}"')
lines.append(' (hs-cleanup!)')
# Pre-`html(...)` setups — emit before element creation so activation
# (init handlers etc.) sees the expected globals.
for name, sx_val in pre_setups:
if name == '__hs_config__':
lines.append(f' {sx_val}')
else:
lines.append(f' (host-set! (host-global "window") "{name}" {sx_val})')
# Compile script blocks so `def X()` functions are available. Wrap in
# guard because not all script forms (e.g. `behavior`) are implemented
# and their parse/compile errors would crash the whole test.
for script in hs_scripts:
clean = clean_hs_script(script)
escaped = clean.replace('\\', '\\\\').replace('"', '\\"')
lines.append(
f' (guard (_e (true nil))'
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 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("\"'").replace('\\', '\\\\').replace('"', '\\"') + '"'
if val.startswith('`') and val.endswith('`'):
inner = val[1:-1]
return '"' + inner.replace('\\', '\\\\').replace('"', '\\"') + '"'
# 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) + ')'
# Objects: { foo: "bar", baz: 1 } → {:foo "bar" :baz 1}
if val.startswith('{') and val.endswith('}'):
inner = val[1:-1].strip()
if not inner:
return '{}'
parts = []
for kv in split_top_level(inner):
kv = kv.strip()
if not kv:
continue
# key: value (key is identifier or quoted string)
m = re.match(r'^(?:"([^"]+)"|\'([^\']+)\'|(\w+))\s*:\s*(.+)$', kv, re.DOTALL)
if not m:
return f'"{val}"'
key = m.group(1) or m.group(2) or m.group(3)
# Try expression translation first (handles identifiers, arrows,
# arith); fall back to literal for things we don't know.
v = js_expr_to_sx(m.group(4)) or js_val_to_sx(m.group(4))
parts.append(f':{key} {v}')
return '{' + ' '.join(parts) + '}'
try:
float(val)
return val
except ValueError:
escaped = val.replace('\\', '\\\\').replace('"', '\\"')
return f'"{escaped}"'
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
_JS_ARROW = re.compile(
r'\(\s*([^)]*?)\s*\)\s*=>\s*(.+)$', re.DOTALL
)
def js_expr_to_sx(expr):
"""Translate a small JS expression to SX. Handles:
- arrow `(args) => body` → (fn (args) <body>)
- object literal `{a: 1}` → {:a 1}
- array literal `[1, 2]` → (list 1 2)
- binary ops `a + b * c` → (+ a (* b c)) — naive flat for now
- bare identifier or literal → as-is
Returns the SX string, or None if we don't know how.
"""
expr = expr.strip()
if not expr:
return None
# Strip trailing semicolons/whitespace.
expr = expr.rstrip(';').strip()
# Arrow functions `(args) => body` or `arg => body`.
am = _JS_ARROW.match(expr)
if am:
args_str = am.group(1).strip()
body_str = am.group(2).strip()
params = [a.strip() for a in args_str.split(',') if a.strip()] if args_str else []
# Arrow body may itself be `({...})` (parenthesised object literal).
if body_str.startswith('(') and body_str.endswith(')'):
inner = body_str[1:-1].strip()
if inner.startswith('{'):
body_str = inner
body_sx = js_expr_to_sx(body_str)
if body_sx is None:
return None
return f'(fn ({" ".join(params)}) {body_sx})'
# function-expression form: `function(args) { return X; }` (or `{ X; }`).
fm = re.match(
r'^function\s*\(([^)]*)\)\s*\{\s*(?:return\s+)?(.+?)\s*;?\s*\}\s*$',
expr, re.DOTALL,
)
if fm:
args_str = fm.group(1).strip()
params = [a.strip() for a in args_str.split(',') if a.strip()] if args_str else []
body_sx = js_expr_to_sx(fm.group(2).strip())
if body_sx is None:
return None
return f'(fn ({" ".join(params)}) {body_sx})'
# Balanced outer parens unwrap (after arrow check, so `(x)` alone works).
if expr.startswith('(') and expr.endswith(')'):
depth = 0
balanced = True
for i, ch in enumerate(expr):
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
if depth == 0 and i != len(expr) - 1:
balanced = False
break
if balanced:
return js_expr_to_sx(expr[1:-1])
# Object literal {a: 1, b: {...}} — reuse js_val_to_sx
if expr.startswith('{') and expr.endswith('}'):
return js_val_to_sx(expr)
# Array literal [a, b]
if expr.startswith('[') and expr.endswith(']'):
return js_val_to_sx(expr)
# Quoted string
if (expr.startswith('"') and expr.endswith('"')) or (expr.startswith("'") and expr.endswith("'")):
return '"' + expr[1:-1].replace('"', '\\"') + '"'
# Numeric literal
try:
float(expr)
return expr
except ValueError:
pass
# Naive binary-op rewriting: split on top-level + - * / and wrap.
for op in ('+', '-', '*', '/'):
parts = []
depth = 0
in_str = None
cur = []
for ch in expr:
if in_str:
cur.append(ch)
if ch == in_str:
in_str = None
continue
if ch in ('"', "'"):
in_str = ch
cur.append(ch)
continue
if ch in '([{':
depth += 1
elif ch in ')]}':
depth -= 1
if ch == op and depth == 0:
parts.append(''.join(cur))
cur = []
else:
cur.append(ch)
if cur:
parts.append(''.join(cur))
if len(parts) > 1 and all(p.strip() for p in parts):
sub = [js_expr_to_sx(p) for p in parts]
if all(s is not None for s in sub):
return '(' + op + ' ' + ' '.join(sub) + ')'
# Method call: o.method(args)
m = re.match(r'^(\w+)\.(\w+)\((.*)\)$', expr, re.DOTALL)
if m:
obj, method, args = m.group(1), m.group(2), m.group(3)
arg_sx = []
for a in (split_top_level(args) if args.strip() else []):
s = js_expr_to_sx(a.strip())
if s is None:
return None
arg_sx.append(s)
return f'(host-call {obj} "{method}" {" ".join(arg_sx)})'.strip()
# Property access: o.prop
m = re.match(r'^(\w+)\.(\w+)$', expr)
if m:
return f'(host-get {m.group(1)} "{m.group(2)}")'
# Bare identifier
if re.match(r'^[A-Za-z_]\w*$', expr):
return expr
return None
def extract_window_setups(body):
"""Find `evaluate(() => { window.NAME = VALUE; ... })` (block form) and
`evaluate(() => window.NAME = VALUE)` (single-expression form) and
return a list of (name, sx_value) pairs. Skips assignments we can't
translate.
"""
setups = []
# Block form: evaluate(() => { window.X = Y; ... })
for em in re.finditer(r'evaluate\(\s*\(\)\s*=>\s*\{', body):
start = em.end()
depth, i, in_str = 1, start, None
while i < len(body) and depth > 0:
ch = body[i]
if in_str:
if ch == in_str and body[i - 1] != '\\':
in_str = None
elif ch in ('"', "'", '`'):
in_str = ch
elif ch == '{':
depth += 1
elif ch == '}':
depth -= 1
i += 1
if depth != 0:
continue
for stmt in split_top_level_chars(body[start:i - 1], ';'):
sm = re.match(r'\s*window\.(\w+)\s*=\s*(.+?)\s*$', stmt, re.DOTALL)
if not sm:
continue
sx_val = js_expr_to_sx(sm.group(2).strip())
if sx_val is not None:
setups.append((sm.group(1), sx_val))
# Single-expression form: evaluate(() => window.X = Y) — no braces.
for em in re.finditer(
r'evaluate\(\s*\(\)\s*=>\s*window\.(\w+)\s*=\s*([^)]+?)\)',
body, re.DOTALL,
):
sx_val = js_expr_to_sx(em.group(2).strip())
if sx_val is not None:
setups.append((em.group(1), sx_val))
return setups
def split_top_level_chars(s, sep_char):
"""Split a string on `sep_char` at top level (depth-0, outside strings)."""
parts = []
depth, in_str, cur = 0, None, []
for ch in s:
if in_str:
cur.append(ch)
if ch == in_str:
in_str = None
continue
if ch in ('"', "'", '`'):
in_str = ch
cur.append(ch)
continue
if ch in '([{':
depth += 1
elif ch in ')]}':
depth -= 1
if ch == sep_char and depth == 0:
parts.append(''.join(cur))
cur = []
else:
cur.append(ch)
if cur:
parts.append(''.join(cur))
return parts
def _js_window_expr_to_sx(expr):
"""Translate a narrow slice of JS into SX for the `make`-style tests.
Only patterns that read state stored on `window` by a prior run() call.
Returns None if the shape isn't covered.
"""
expr = expr.strip().rstrip(';').strip()
# `window.X instanceof TYPE` or `window['X'] instanceof TYPE` — check non-null.
m = re.match(r"window(?:\.(\w+)|\[\s*['\"]([^'\"]+)['\"]\s*\])\s+instanceof\s+\w+$", expr)
if m:
key = m.group(1) or m.group(2)
return f'(not (nil? (host-get (host-global "window") "{key}")))'
# `window.X.classList.contains("Y")` → dom-has-class?
m = re.match(
r"window(?:\.(\w+)|\[\s*['\"]([^'\"]+)['\"]\s*\])\.classList\.contains\(\s*['\"]([^'\"]+)['\"]\s*\)$",
expr,
)
if m:
key = m.group(1) or m.group(2)
cls = m.group(3)
return f'(dom-has-class? (host-get (host-global "window") "{key}") "{cls}")'
# `window.X.Y.Z...` or `window['X'].Y.Z` — chained host-get.
m = re.match(r"window(?:\.(\w+)|\[\s*['\"]([^'\"]+)['\"]\s*\])((?:\.\w+)*)$", expr)
if m:
key = m.group(1) or m.group(2)
rest = m.group(3) or ''
sx = f'(host-get (host-global "window") "{key}")'
for prop in re.findall(r'\.(\w+)', rest):
sx = f'(host-get {sx} "{prop}")'
return sx
return None
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.
"""
if test['name'] in SKIP_TEST_NAMES:
return emit_skip_test(test)
body = test.get('body', '')
lines = []
safe_name = sx_name(test['name'])
# Special case: logAll config test. Body sets `_hyperscript.config.logAll = true`,
# then mutates an element's innerHTML and calls `_hyperscript.processNode`.
# Our runtime exposes this via hs-set-log-all! + hs-log-captured; we reuse
# the same mechanics without re-parsing the body.
if 'logAll' in body and '_hyperscript.config.logAll' in body:
return (
f' (deftest "{safe_name}"\n'
f' (hs-cleanup!)\n'
f' (hs-clear-log-captured!)\n'
f' (hs-set-log-all! true)\n'
f' (let ((wa (dom-create-element "div")))\n'
f' (dom-set-inner-html wa "<div _=\\"on click add .foo\\"></div>")\n'
f' (hs-boot-subtree! wa))\n'
f' (hs-set-log-all! false)\n'
f' (assert= (some (fn (l) (string-contains? l "hyperscript:"))\n'
f' (hs-get-log-captured))\n'
f' true)\n'
f' )'
)
lines.append(f' (deftest "{safe_name}"')
assertions = []
# Window setups from `evaluate(() => { window.X = Y })` blocks.
# These get merged into local_pairs so the HS expression can reference them.
window_setups = extract_window_setups(body)
def emit_eval(hs_expr, expected_sx, extra_locals=None):
"""Emit an assertion using eval-hs / eval-hs-locals / eval-hs-with-me
as appropriate, given the window setups and any per-call locals.
"""
pairs = list(window_setups) + list(extra_locals or [])
if pairs:
locals_sx = '(list ' + ' '.join(
f'(list (quote {n}) {v})' for n, v in pairs
) + ')'
return f' (assert= (eval-hs-locals "{hs_expr}" {locals_sx}) {expected_sx})'
return f' (assert= (eval-hs "{hs_expr}") {expected_sx})'
# 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.
# Numeric me uses eval-hs-with-me; other me values get bound as a local.
me_num_match = re.search(r'\bme:\s*(\d+)\b', opts_str)
me_val_match = re.search(r'\bme:\s*(\[[^\]]*\]|\{[^}]*\}|"[^"]*"|\'[^\']*\')', opts_str)
# Locals: balanced-brace extraction so nested arrays/objects don't truncate.
locals_idx = opts_str.find('locals:')
extra = []
if locals_idx >= 0:
open_idx = opts_str.find('{', locals_idx)
if open_idx >= 0:
depth, in_str, end_idx = 1, None, -1
for i in range(open_idx + 1, len(opts_str)):
ch = opts_str[i]
if in_str:
if ch == in_str and opts_str[i - 1] != '\\':
in_str = None
continue
if ch in ('"', "'", '`'):
in_str = ch
continue
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
end_idx = i
break
if end_idx > open_idx:
for kv in split_top_level(opts_str[open_idx + 1:end_idx]):
kv = kv.strip()
m2 = re.match(r'^(\w+)\s*:\s*(.+)$', kv, re.DOTALL)
if m2:
extra.append((m2.group(1), js_val_to_sx(m2.group(2).strip())))
if me_val_match:
extra.append(('me', js_val_to_sx(me_val_match.group(1))))
# `result: X` (or `it: X`) binds `it` — upstream `run("expr", { result: ... })`
# uses the value as the implicit `it` for possessive expressions like `its foo`.
result_match = re.search(
r'\b(?:result|it):\s*(\[[^\]]*\]|\{[^}]*(?:\{[^}]*\}[^}]*)?\}|"[^"]*"|\'[^\']*\'|[\w.]+)',
opts_str,
)
if result_match:
extra.append(('it', js_val_to_sx(result_match.group(1))))
if me_num_match and not (window_setups or extra):
assertions.append(f' (assert= (eval-hs-with-me "{hs_expr}" {me_num_match.group(1)}) {expected_sx})')
else:
# If there are other locals/setups but `me: <num>` is present too,
# bind it as a local so the HS expression can see it.
if me_num_match and not me_val_match:
extra.append(('me', me_num_match.group(1)))
assertions.append(emit_eval(hs_expr, expected_sx, extra))
# 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(emit_eval(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 single-line TODO comment. Collapse
# whitespace inside the JS literal so the `;;` prefix covers the
# whole line; a multi-line `{...}` would leak SX-invalid text
# onto subsequent lines and break the parse.
obj_str = re.sub(r'\s+', ' ', m.group(3)).strip()
assertions.append(f' ;; TODO: assert= (eval-hs "{hs_expr}") against {obj_str}')
# Pattern 2-values: DOM-constructing evaluate returning _hyperscript result.
# const result = await evaluate(() => {
# const node = document.createElement("<tag>")
# node.innerHTML = `<html>` (or direct property assignments)
# return _hyperscript("<hs-expr>", { locals: { <name>: node } })
# })
# expect(result.<prop>).toBe/toEqual(<val>)
if not assertions:
pv = re.search(
r'const\s+\w+\s*=\s*(?:await\s+)?evaluate\(\s*\(\)\s*=>\s*\{'
r'(.*?)'
r'return\s+_hyperscript\(\s*(["\x27`])(.+?)\2'
r'(?:\s*,\s*\{\s*locals:\s*\{\s*(\w+)\s*:\s*(\w+)\s*\}\s*\})?'
r'\s*\)\s*\}\s*\)\s*;?',
body, re.DOTALL,
)
if pv:
setup_block = pv.group(1)
hs_src = extract_hs_expr(pv.group(3))
local_name = pv.group(4)
# node variable from createElement
cm = re.search(
r'const\s+(\w+)\s*=\s*document\.createElement\(\s*["\x27](\w+)["\x27]\s*\)',
setup_block,
)
if cm:
node_tag = cm.group(2)
setup_lines = [f'(let ((_node (dom-create-element "{node_tag}")))']
# node.innerHTML = `...`
ih = re.search(
r'\w+\.innerHTML\s*=\s*(["\x27`])((?:\\.|[^\\])*?)\1',
setup_block, re.DOTALL,
)
if ih:
raw = ih.group(2)
clean = re.sub(r'\s+', ' ', raw).strip()
esc = clean.replace('\\', '\\\\').replace('"', '\\"')
setup_lines.append(f' (dom-set-inner-html _node "{esc}")')
# node.prop = val (e.g. node.name = "x", node.value = "y")
for pm in re.finditer(
r'\w+\.(\w+)\s*=\s*(["\x27])(.*?)\2\s*;?', setup_block, re.DOTALL,
):
prop = pm.group(1)
if prop == 'innerHTML':
continue
val = pm.group(3).replace('\\', '\\\\').replace('"', '\\"')
setup_lines.append(f' (host-set! _node "{prop}" "{val}")')
# Collect post-return expressions that modify node (e.g. `select.value = 'cat'`)
# We cover the simple `var select = node.querySelector("select")`
# followed by `select.value = "X"` pattern.
local_sx = (
'(list '
+ (f'(list (quote {local_name}) _node)' if local_name else '')
+ ')'
)
call = f'(eval-hs-locals "{hs_src}" {local_sx})' if local_name else f'(eval-hs "{hs_src}")'
setup_lines.append(f' (let ((_result {call}))')
# Find expect assertions tied to `result`. Allow hyphens in
# bracket keys (e.g. result["test-name"]) and numeric index
# access (result.gender[0]).
extra = []
for em in re.finditer(
r'expect\(\s*result'
r'(?:\.(\w+)(?:\[(\d+)\])?'
r'|\[\s*["\x27]([\w-]+)["\x27]\s*\](?:\[(\d+)\])?)?'
r'\s*\)\.(toBe|toEqual)\(([^)]+)\)',
body,
):
key = em.group(1) or em.group(3)
idx = em.group(2) or em.group(4)
val_raw = em.group(6).strip()
target = '_result' if not key else f'(host-get _result "{key}")'
if idx is not None:
target = f'(nth {target} {idx})'
expected_sx = js_val_to_sx(val_raw)
extra.append(f' (assert= {target} {expected_sx})')
# Also handle toEqual([list]) where the regex's [^)] stops
# at the first `]` inside the brackets. Re-scan for arrays.
for em in re.finditer(
r'expect\(\s*result(?:\.(\w+)|\[\s*["\x27]([\w-]+)["\x27]\s*\])?\s*\)\.toEqual\((\[.*?\])\)',
body, re.DOTALL,
):
key = em.group(1) or em.group(2)
target = '_result' if not key else f'(host-get _result "{key}")'
expected_sx = js_val_to_sx(em.group(3))
extra.append(f' (assert= {target} {expected_sx})')
if extra:
for a in extra:
setup_lines.append(a)
setup_lines.append(' ))')
assertions.append(' ' + '\n '.join(setup_lines))
# Pattern 2: var result = await run(`expr`, opts); expect(result...).toBe/toEqual(val)
# Reassignments are common (`result = await run(...)` repeated for multiple
# checks). Walk the body in order, pairing each expect(result) with the
# most recent preceding run().
if not assertions:
# Only match when the declared var is actually bound to a run() call —
# otherwise tests that bind to `evaluate(...)` (e.g. window-mutating
# make tests) would be mis-paired to the run() return value.
decl_match = re.search(r'(?:var|let|const)\s+(\w+)\s*=\s*(?:await\s+)?run\(', body)
if decl_match:
var_name = decl_match.group(1)
# Find every run() occurrence (with or without var = prefix), and
# capture per-call `{locals: {...}}` opts (balanced-brace).
# The trailing `_RUN_ARGS\)` anchors the lazy `(.+?)\1` so it
# picks the *outer* HS-source quote, not the first inner `\'`.
run_iter = list(re.finditer(
r'(?:(?:var|let|const)\s+\w+\s*=\s*|' + re.escape(var_name) + r'\s*=\s*)?' +
_RUN_OPEN + _RUN_ARGS + r'\)', body, re.DOTALL
))
def parse_run_locals(rm):
"""If the run() match has `, {locals: {...}}` or `{ me: <X> }`
in its args, return (name, sx_value) pairs; else []."""
# Args between the closing HS-source quote and run's `)`.
args_str = body[rm.end(2) + 1:rm.end() - 1]
pairs = []
# `me: <literal>` (object/array/string/number) bound as local.
me_m = re.search(
r'\bme:\s*(\{[^}]*\}|\[[^\]]*\]|"[^"]*"|\'[^\']*\'|\d+(?:\.\d+)?)',
args_str)
if me_m:
pairs.append(('me', js_val_to_sx(me_m.group(1))))
# `result: <literal>` binds `it` — upstream `run("its X", {result: obj})`
# passes `obj` as the implicit `it` for possessive expressions.
result_m = re.search(
r'\bresult:\s*(\{[^}]*(?:\{[^}]*\}[^}]*)?\}|\[[^\]]*\]|"[^"]*"|\'[^\']*\'|\d+(?:\.\d+)?)',
args_str)
if result_m:
pairs.append(('it', js_val_to_sx(result_m.group(1))))
lm = re.search(r'locals:\s*\{', args_str)
if not lm:
return pairs
# Balanced-brace from after `locals: {`.
start = rm.end(2) + 1 + lm.end()
d, in_str, end = 1, None, -1
for i in range(start, len(body)):
ch = body[i]
if in_str:
if ch == in_str and body[i - 1] != '\\':
in_str = None
continue
if ch in ('"', "'", '`'):
in_str = ch
continue
if ch == '{':
d += 1
elif ch == '}':
d -= 1
if d == 0:
end = i
break
if end < 0:
return pairs
for kv in split_top_level(body[start:end]):
kv = kv.strip()
km = re.match(r'^(\w+)\s*:\s*(.+)$', kv, re.DOTALL)
if km:
pairs.append((km.group(1), js_val_to_sx(km.group(2).strip())))
return pairs
# Pre-compute per-run locals (window_setups + per-call locals).
run_data = []
for rm in run_iter:
local_pairs = parse_run_locals(rm)
merged = list(window_setups) + local_pairs
run_data.append((rm.start(), rm.end(), extract_hs_expr(rm.group(2)), merged))
def call_for(hs_expr, pairs):
if pairs:
locals_sx = '(list ' + ' '.join(
f'(list (quote {n}) {v})' for n, v in pairs) + ')'
return f'(eval-hs-locals "{hs_expr}" {locals_sx})'
return f'(eval-hs "{hs_expr}")'
def run_at(pos):
"""Return (hs_expr, pairs) for the most recent run() that ends before `pos`."""
last = None
for rd in run_data:
if rd[1] >= 0 and rd[1] < pos:
last = rd
return last
def emit_for(hs_expr, pairs, expected_sx, prop=None):
call = call_for(hs_expr, pairs)
if prop:
return f' (assert= (host-get {call} "{prop}") {expected_sx})'
return f' (assert= {call} {expected_sx})'
for m in re.finditer(
r'expect\((' + re.escape(var_name) + r'(?:\["[^"]+"\]|\.\w+)?)\)\.toBe\(([^)]+)\)',
body
):
rd = run_at(m.start())
if rd is None:
continue
_, _, hs_expr, pairs = rd
accessor = m.group(1)
expected_sx = js_val_to_sx(m.group(2))
prop_m = re.search(r'\["([^"]+)"\]|\.(\w+)', accessor[len(var_name):])
prop = prop_m.group(1) or prop_m.group(2) if prop_m else None
assertions.append(emit_for(hs_expr, pairs, expected_sx, prop))
for m in re.finditer(
r'expect\(' + re.escape(var_name) + r'(?:\.\w+)?\)\.toEqual\((\[.*?\])\)',
body, re.DOTALL
):
rd = run_at(m.start())
if rd is None:
continue
_, _, hs_expr, pairs = rd
expected_sx = js_val_to_sx(m.group(1))
assertions.append(emit_for(hs_expr, pairs, expected_sx))
for m in re.finditer(
r'expect\(' + re.escape(var_name) + r'\.map\(\w+\s*=>\s*\w+\.(\w+)\)\)\.toEqual\((\[.*?\])\)',
body, re.DOTALL
):
rd = run_at(m.start())
if rd is None:
continue
_, _, hs_expr, pairs = rd
prop = m.group(1)
expected_sx = js_val_to_sx(m.group(2))
call = call_for(hs_expr, pairs)
assertions.append(f' (assert= (map (fn (x) (get x "{prop}")) {call}) {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, ...}. Collect (name, value-sx) pairs.
local_pairs = []
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_pairs.append((lname, lval))
# Also accept ES6 shorthand `{foo}` (= `{foo: foo}`): for every
# bare identifier in locals_str not already captured, look up
# `const <name> = <value>;` earlier in the test body.
taken = {n for n, _ in local_pairs}
for sh in re.finditer(r'(?<![\w:])(\w+)(?![\w:])', locals_str):
lname = sh.group(1)
if lname in taken:
continue
const_match = re.search(
r'const\s+' + re.escape(lname) + r'\s*=\s*(.+?);',
body, re.DOTALL)
if const_match:
lval = js_val_to_sx(const_match.group(1).strip())
local_pairs.append((lname, lval))
taken.add(lname)
# SX list of (symbol value) pairs for eval-hs-locals
locals_sx = '(list ' + ' '.join(f'(list (quote {n}) {v})' for n, v in local_pairs) + ')' if local_pairs else '(list)'
# Find expect().toBe() or .toEqual(). eval-hs/eval-hs-locals return
# the final value of `it` after the script runs, so assert on the
# return value directly — `it` is not in the outer SX scope.
for m in re.finditer(r'expect\([^)]*\)\.toBe\(([^)]+)\)', body):
expected_sx = js_val_to_sx(m.group(1))
if local_pairs:
assertions.append(f' (assert= (eval-hs-locals "{hs_expr}" {locals_sx}) {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_pairs:
assertions.append(f' (assert= (eval-hs-locals "{hs_expr}" {locals_sx}) {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_pairs:
assertions.append(f' (assert (not (nil? (eval-hs-locals "{hs_expr}" {locals_sx}))))')
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_pairs:
assertions.append(f' (assert= (len (eval-hs-locals "{hs_expr}" {locals_sx})) {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 2e: run() with side-effects on window, checked via
# const X = await evaluate(() => <js-expr>); expect(X).toBe(val)
# The const holds the evaluated JS expr, not the run() return value,
# so we need to translate <js-expr> into SX and assert against that.
if not assertions:
run_iter = list(re.finditer(
r'(?:await\s+)?run\((?:String\.raw)?(' + _Q + r')(.+?)\1' + _RUN_ARGS + r'\)',
body, re.DOTALL,
))
if run_iter:
# Map `const X = await evaluate(() => EXPR)` assignments by name.
eval_binds = {}
for em in re.finditer(
r'(?:var|let|const)\s+(\w+)\s*=\s*(?:await\s+)?evaluate\('
r'\s*\(\)\s*=>\s*(.+?)\)\s*;',
body, re.DOTALL,
):
eval_binds[em.group(1)] = em.group(2).strip()
# Inline pattern: expect(await evaluate(() => EXPR)).toBe(val)
inline_matches = list(re.finditer(
r'expect\(\s*(?:await\s+)?evaluate\(\s*\(\)\s*=>\s*(.+?)\)\s*\)'
r'\s*\.toBe\(([^)]+)\)',
body, re.DOTALL,
))
name_matches = list(re.finditer(
r'expect\((\w+)\)\.toBe\(([^)]+)\)', body,
))
hs_exprs_emitted = set()
for rm in run_iter:
hs_src = extract_hs_expr(rm.group(2))
if hs_src in hs_exprs_emitted:
continue
hs_exprs_emitted.add(hs_src)
assertions.append(f' (eval-hs "{hs_src}")')
for em in inline_matches:
sx_expr = _js_window_expr_to_sx(em.group(1).strip())
if sx_expr is None:
continue
expected_sx = js_val_to_sx(em.group(2))
assertions.append(f' (assert= {sx_expr} {expected_sx})')
for em in name_matches:
name = em.group(1)
if name not in eval_binds:
continue
sx_expr = _js_window_expr_to_sx(eval_binds[name])
if sx_expr is None:
continue
expected_sx = js_val_to_sx(em.group(2))
assertions.append(f' (assert= {sx_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_compile_only_test(test):
"""Emit a test that merely verifies the HS script block(s) compile.
Used when the test's HTML contains only <script type=text/hyperscript>
blocks (no DOM elements) and the upstream action is `(see body)` with
no usable body. This prevents stub tests from throwing
`NOT IMPLEMENTED` errors — at minimum we verify the script parses.
Evaluation is wrapped in a guard: some `def` bodies eagerly reference
host globals (e.g. `window`) in async branches that fire during
definition-time bytecode emission, which would spuriously fail an
otherwise-syntactic check.
"""
hs_scripts = extract_hs_scripts(test.get('html', ''))
if not hs_scripts:
return None
name = sx_name(test['name'])
lines = [f' (deftest "{name}"', ' (hs-cleanup!)']
for script in hs_scripts:
clean = clean_hs_script(script)
escaped = clean.replace('\\', '\\\\').replace('"', '\\"')
lines.append(
f' (guard (_e (true nil))'
f' (eval-expr-cek (hs-to-sx (hs-compile "{escaped}"))))')
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:
# Script-only test — compile the HS so we at least verify it parses.
return generate_compile_only_test(test)
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, test.get('action', '') or '')
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, elements, var_names)
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', ''))),
t.get('action', '') or '')))
)
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(';; Bind `window` and `document` as plain SX symbols so HS code that')
output.append(';; references them (e.g. `window.tmp`) can resolve through the host.')
output.append('(define window (host-global "window"))')
output.append('(define document (host-global "document"))')
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 either the expression')
output.append(';; value or `it` (whichever is non-nil). Multi-statement scripts that')
output.append(';; mutate `it` (e.g. `pick first 3 of arr; set $test to it`) get `it` back;')
output.append(';; bare expressions (e.g. `foo.foo`) get the expression value back.')
output.append('(define _hs-wrap-body')
output.append(' (fn (sx)')
output.append(' ;; Wrap body to capture return via `it`. `event` default is always nil.')
output.append(' ;; `it` is NOT shadowed here — callers (eval-hs-locals) may pre-bind it.')
output.append(' (list (quote let)')
output.append(' (list (list (quote event) nil))')
output.append(' (list (quote let)')
output.append(' (list (list (quote _ret) sx))')
output.append(' (list (quote if) (list (quote nil?) (quote _ret)) (quote it) (quote _ret))))))')
output.append('')
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))')
output.append(' (list (quote let) (list (list (quote it) nil) (list (quote event) nil)) sx)))))')
output.append(' (guard')
output.append(' (_e')
output.append(' (true')
output.append(' (if')
output.append(' (and (list? _e) (= (first _e) "hs-return"))')
output.append(' (nth _e 1)')
output.append(' (raise _e))))')
output.append(' (handler nil))))))')
output.append('')
output.append(';; Evaluate a hyperscript expression with locals. bindings = list of (symbol value).')
output.append(';; Locals are injected as a `let` wrapping the compiled body, then evaluated')
output.append(';; in a fresh CEK env. Avoids `apply` (whose JIT path can loop on some forms).')
output.append('(define eval-hs-locals')
output.append(' (fn (src bindings)')
output.append(' ;; Also expose bindings on the `window` global so tests that reference')
output.append(' ;; window.X (common in upstream tests) can resolve them.')
output.append(' (for-each (fn (b) (host-set! (host-global "window") (str (first b)) (nth b 1))) bindings)')
output.append(' (let ((sx (hs-to-sx (hs-compile src))))')
output.append(' ;; Build (let ((name1 (quote val1)) ...) <wrap-body>)')
output.append(' (let ((let-binds (map (fn (b) (list (first b) (list (quote quote) (nth b 1)))) bindings)))')
output.append(' (let ((wrapped (list (quote let) let-binds (_hs-wrap-body sx))))')
output.append(' (let ((thunk (list (quote fn) (list (quote me)) wrapped)))')
output.append(' (let ((handler (eval-expr-cek thunk)))')
output.append(' (guard')
output.append(' (_e')
output.append(' (true')
output.append(' (if')
output.append(' (and (list? _e) (= (first _e) "hs-return"))')
output.append(' (nth _e 1)')
output.append(' (raise _e))))')
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)) (_hs-wrap-body sx)))))')
output.append(' (guard')
output.append(' (_e')
output.append(' (true')
output.append(' (if')
output.append(' (and (list? _e) (= (first _e) "hs-return"))')
output.append(' (nth _e 1)')
output.append(' (raise _e))))')
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
# SKIP emissions still go through generate_test() → emit_skip_test;
# detect them here so the counter reports real coverage.
if 'SKIP (' in sx:
cat_stub += 1
cat_gen -= 1
else:
output.append(emit_untranslatable_test(t))
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}')