Two generator changes: (a) `parse_run_locals` for Pattern 2
(`var R = await run(...)`) now recognises `result: <literal>` in the
opts dict and binds it to `it` so `run("its foo", {result: {foo: "foo"}})`
produces `(eval-hs-locals "its foo" (list (list (quote it) {:foo "foo"})))`.
Also adds the same extraction to Pattern 1 (`expect(run(...)).toBe(...)`).
(b) `_hs-wrap-body` emitted by the generator no longer shadows `it` to
nil — it only binds `event` — so eval-hs-locals's outer `it` binding is
visible inside the wrapped body. `eval-hs` still binds `it` nil at its
own `fn` wrapper so nothing regresses.
Suite hs-upstream-expressions/possessiveExpression: 22/23 → 23/23.
Smoke 0-195: 162/195 unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2593 lines
108 KiB
Python
2593 lines
108 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
|
|
|
|
# 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
|
|
|
|
if not seen_html:
|
|
continue
|
|
if add_action(stmt_na):
|
|
continue
|
|
add_assertion(stmt_na)
|
|
|
|
return pre_setups, ops
|
|
|
|
|
|
# ── Test generation ───────────────────────────────────────────────
|
|
|
|
def process_hs_val(hs_val):
|
|
"""Process a raw HS attribute value: collapse whitespace, insert 'then' separators."""
|
|
# Convert escaped newlines/tabs to real whitespace
|
|
hs_val = hs_val.replace('\\n', '\n').replace('\\t', ' ')
|
|
# Preserve escaped quotes (\" → placeholder), strip remaining backslashes, restore
|
|
hs_val = hs_val.replace('\\"', '\x00QUOT\x00')
|
|
hs_val = hs_val.replace('\\', '')
|
|
hs_val = hs_val.replace('\x00QUOT\x00', '\\"')
|
|
# Strip line comments BEFORE newline collapse — once newlines become `then`,
|
|
# an unterminated `//` / ` --` comment would consume the rest of the input.
|
|
hs_val = re.sub(r'//[^\n]*', '', hs_val)
|
|
hs_val = re.sub(r'(^|\s)--[^\n]*', r'\1', 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'])
|
|
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}')
|