#!/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--)), 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 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"(.*?)", 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".*?", '', html, flags=re.DOTALL) # Remove | separators html = html.replace(' | ', '') # Note: previously we collapsed `\"` → `"` here, but that destroys legitimate # HS string escapes inside single-quoted `_='...'` attributes (e.g. nested # button HTML in `properly processes hyperscript X` tests). HTMLParser handles # backslashes in attribute values as literal characters, so we leave them. elements = [] stack = [] class Parser(HTMLParser): def handle_starttag(self, tag, attrs): el = { 'tag': tag, 'id': None, 'classes': [], 'hs': None, 'attrs': {}, 'inner': '', 'depth': len(stack), 'children': [], 'parent_idx': None } BOOL_ATTRS = {'checked', 'selected', 'disabled', 'multiple', 'required', 'readonly', 'autofocus', 'hidden', 'open'} for name, val in attrs: if name == 'id': el['id'] = val elif name == 'class': el['classes'] = (val or '').split() elif name == '_': el['hs'] = val elif name == 'style': el['attrs']['style'] = val or '' elif val is not None: el['attrs'][name] = val elif name in BOOL_ATTRS: el['attrs'][name] = '' # Track parent-child relationship if stack: parent = stack[-1] # Find parent's index in elements list parent_idx = None for i, e in enumerate(elements): if e is parent: parent_idx = i break el['parent_idx'] = parent_idx parent['children'].append(len(elements)) stack.append(el) elements.append(el) def handle_endtag(self, tag): if stack and stack[-1]['tag'] == tag: stack.pop() def handle_data(self, data): # Only capture text for elements with no children if stack and len(stack[-1]['children']) == 0: stack[-1]['inner'] += data.strip() Parser().feed(html) return elements # ── Variable naming ─────────────────────────────────────────────── def assign_var_names(elements): """Assign unique SX variable names to elements.""" var_names = [] used_names = set() for i, el in enumerate(elements): if el['id']: var = f'_el-{el["id"]}' else: var = f'_el-{el["tag"]}' if var in used_names: var = f'{var}{i}' used_names.add(var) var_names.append(var) return var_names # ── Chai-style parsers (v0.9.14 master tests) ──────────────────── def parse_action(action, ref): """Convert upstream Chai-style action to SX. Returns list of SX expressions.""" if not action or action == '(see body)': return [] exprs = [] for part in action.split(';'): part = part.strip() if not part: continue m = re.match(r'(\w+)\.click\(\)', part) if m: exprs.append(f'(dom-dispatch {ref(m.group(1))} "click" nil)') continue m = re.match(r'(\w+)\.dispatchEvent\(new CustomEvent\("([\w:.-]+)"\s*(?:,\s*\{(.*)\})?', part) if m: detail_expr = 'nil' body = m.group(3) if body: dm = re.search(r'detail:\s*"([^"]*)"', body) if dm: detail_expr = f'"{dm.group(1)}"' else: dm = re.search(r'detail:\s*\{([^}]*)\}', body) if dm: pairs = re.findall(r'(\w+):\s*"([^"]*)"', dm.group(1)) if pairs: items = ' '.join(f':{k} "{v}"' for k, v in pairs) detail_expr = '{' + items + '}' exprs.append(f'(dom-dispatch {ref(m.group(1))} "{m.group(2)}" {detail_expr})') continue m = re.match(r'(\w+)\.setAttribute\("([\w-]+)",\s*"([^"]*)"\)', part) if m: exprs.append(f'(dom-set-attr {ref(m.group(1))} "{m.group(2)}" "{m.group(3)}")') continue m = re.match(r'(\w+)\.focus\(\)', part) if m: exprs.append(f'(dom-focus {ref(m.group(1))})') continue m = re.match(r'(\w+)\.appendChild\(document\.createElement\("(\w+)"\)', part) if m: exprs.append(f'(dom-append {ref(m.group(1))} (dom-create-element "{m.group(2)}"))') continue safe = re.sub(r'[\'\"$@`(),;\\#\[\]{}]', '_', part[:40]) exprs.append(f';; SKIP action: {safe}') return exprs def parse_checks(check): """Convert Chai assertions to SX assert forms. Returns list of SX expressions. Only keeps post-action assertions (last occurrence per expression).""" if not check or check == '(no explicit assertion)': return [] all_checks = [] for part in check.split(' && '): part = part.strip() if not part: continue m = re.match(r'(\w+)\.classList\.contains\("([^"]+)"\)\.should\.equal\((true|false)\)', part) if m: name, cls, expected = m.group(1), m.group(2), m.group(3) if expected == 'true': all_checks.append(('class', name, cls, True)) else: all_checks.append(('class', name, cls, False)) continue m = re.match(r'(\w+)\.innerHTML\.should\.equal\("([^"]*)"\)', part) if m: all_checks.append(('innerHTML', m.group(1), m.group(2), None)) continue m = re.match(r"(\w+)\.innerHTML\.should\.equal\('((?:[^'\\]|\\.)*)'\)", part) if m: all_checks.append(('innerHTML', m.group(1), m.group(2), None)) continue m = re.match(r'(\w+)\.innerHTML\.should\.equal\((.+)\)', part) if m: all_checks.append(('innerHTML', m.group(1), m.group(2), None)) continue m = re.match(r'(\w+)\.textContent\.should\.equal\("([^"]*)"\)', part) if m: all_checks.append(('textContent', m.group(1), m.group(2), None)) continue m = re.match(r"(\w+)\.textContent\.should\.equal\('((?:[^'\\]|\\.)*)'\)", part) if m: all_checks.append(('textContent', m.group(1), m.group(2), None)) continue m = re.match(r'(\w+)\.style\.(\w+)\.should\.equal\("([^"]*)"\)', part) if m: all_checks.append(('style', m.group(1), m.group(2), m.group(3))) continue m = re.match(r'(\w+)\.getAttribute\("([^"]+)"\)\.should\.equal\("([^"]*)"\)', part) if m: all_checks.append(('attr', m.group(1), m.group(2), m.group(3))) continue m = re.match(r'(\w+)\.hasAttribute\("([^"]+)"\)\.should\.equal\((true|false)\)', part) if m: all_checks.append(('hasAttr', m.group(1), m.group(2), m.group(3) == 'true')) continue m = re.match(r'getComputedStyle\((\w+)\)\.(\w+)\.should\.equal\("([^"]*)"\)', part) if m: all_checks.append(('computedStyle', m.group(1), m.group(2), m.group(3))) continue m = re.match(r'assert\.isNull\((\w+)\.parentElement\)', part) if m: all_checks.append(('noParent', m.group(1), None, None)) continue m = re.match(r'assert\.isNotNull\((\w+)\.parentElement\)', part) if m: all_checks.append(('hasParent', m.group(1), None, None)) continue m = re.match(r'(\w+)\.value\.should\.equal\("([^"]*)"\)', part) if m: all_checks.append(('value', m.group(1), m.group(2), None)) continue all_checks.append(('skip', part[:60], None, None)) # Deduplicate: keep last per (element, property). # Pre-action and post-action assertions for the same property get the same key, # so only the post-action assertion (the last one) survives. seen = {} for c in all_checks: typ, name = c[0], c[1] if typ in ('class',): key = (name, 'class', c[2]) elif typ in ('innerHTML', 'textContent'): key = (name, 'content') elif typ in ('style', 'computedStyle'): key = (name, 'style', c[2]) elif typ in ('attr', 'hasAttr'): key = (name, 'attr', c[2]) elif typ in ('noParent', 'hasParent'): key = (name, 'parent') elif typ in ('value',): key = (name, 'value') else: key = (typ, name, c[2]) seen[key] = c return list(seen.values()) def make_ref_fn(elements, var_names, action_str=''): """Create a ref function that maps upstream JS variable names to SX let-bound variables. Upstream naming conventions: - div, form, button, select — first element of that tag type - d1, d2, d3 — elements by position (1-indexed) - div1, div2, div3 — divs by position among same tag (1-indexed) - bar, btn, A, B — elements by ID If action_str mentions a non-tag variable name (like `bar`), that variable names the handler-bearing element. Bare tag-name references in checks (like `div`) then refer to a *different* element — prefer the first ID'd element of that tag. """ # Map tag → first UNNAMED top-level element of that tag (no id) tag_to_unnamed = {} # Map tag → first ID'd top-level element of that tag tag_to_id = {} # Map tag → list of vars for top-level elements of that tag (ordered) tag_to_all = {} id_to_var = {} # Top-level element vars for positional refs (d1, d2, ...) top_level_vars = [] first_var = var_names[0] if var_names else '_el-div' for i, el in enumerate(elements): tag = el['tag'] if el['id']: id_to_var[el['id']] = var_names[i] # Only use top-level elements for tag/positional mapping if el.get('depth', 0) == 0: top_level_vars.append(var_names[i]) if tag not in tag_to_unnamed and not el['id']: tag_to_unnamed[tag] = var_names[i] if tag not in tag_to_id and el['id']: tag_to_id[tag] = var_names[i] if tag not in tag_to_all: tag_to_all[tag] = [] tag_to_all[tag].append(var_names[i]) tags = {'div', 'form', 'button', 'input', 'span', 'p', 'a', 'section', 'ul', 'li', 'select', 'textarea', 'details', 'dialog', 'template', 'output'} # Names referenced in the action (click/dispatch/focus/setAttribute/…). # Used to disambiguate bare tag refs in checks. action_vars = set(re.findall( r'\b(\w+)\.(?:click|dispatchEvent|focus|setAttribute|appendChild)', action_str or '')) # If the action targets a non-tag name (like `bar`), that name IS the # handler-bearing (usually unnamed) element — so bare `div` in checks # most likely refers to an *other* element (often the ID'd one). action_uses_alias = any(n not in tags for n in action_vars) # Build var→element lookup for depth checks var_to_el = {var_names[i]: elements[i] for i in range(len(var_names))} def ref(name): # Special case for `d1`, `d2`, ... (upstream convention `var d1 = make(HTML)` # binds to the outermost wrapper). If the HTML also has an element with # id='d1' *nested inside* the wrapper, the JS variable shadows it — so # `d1.click()` / `d1.innerHTML` in the check refer to the wrapper, not # the nested element. Prefer the top-level positional element here. pos_match = re.match(r'^d(\d+)$', name) if pos_match and name in id_to_var: id_el = var_to_el.get(id_to_var[name]) if id_el is not None and id_el.get('depth', 0) > 0: idx = int(pos_match.group(1)) - 1 if 0 <= idx < len(top_level_vars): return top_level_vars[idx] # Exact ID match first if name in id_to_var: return id_to_var[name] # Bare tag name → first UNNAMED element of that tag (upstream convention: # named elements use their ID, unnamed use their tag). if name in tags: # Disambiguation: if the action names the handler-bearing element # via an alias (`bar`) and this tag has both unnamed AND id'd # variants, the check's bare `div` refers to the ID'd one. if (action_uses_alias and name not in action_vars and name in tag_to_unnamed and name in tag_to_id): return tag_to_id[name] if name in tag_to_unnamed: return tag_to_unnamed[name] if name in tag_to_all and tag_to_all[name]: # Static element of that tag exists — use it return tag_to_all[name][0] # No static element of this tag: it must be dynamically inserted # by the hyperscript (e.g. `button` after the handler creates one). # Query the DOM at action/check time with a tag selector. return f'(dom-query "{name}")' # Tag + number: div1→1st div, div2→2nd div, form1→1st form, etc. m = re.match(r'^([a-z]+)(\d+)$', name) if m: tag_part, num = m.group(1), int(m.group(2)) if tag_part in tag_to_all: idx = num - 1 # 1-indexed if 0 <= idx < len(tag_to_all[tag_part]): return tag_to_all[tag_part][idx] # Positional: d1→1st top-level element, d2→2nd, d3→3rd, etc. m = re.match(r'^d(\d+)$', name) if m: idx = int(m.group(1)) - 1 # 1-indexed if 0 <= idx < len(top_level_vars): return top_level_vars[idx] # Short aliases: btn → look up as ID if name == 'btn': return id_to_var.get('btn', tag_to_unnamed.get('button', first_var)) # Single-letter or short lowercase → try as ID, fallback to first element if re.match(r'^[a-z]+$', name) and len(elements) > 0: return first_var return f'(dom-query-by-id "{name}")' return ref TAG_NAMES_FOR_REF = {'div', 'form', 'button', 'input', 'span', 'p', 'a', 'section', 'ul', 'li', 'select', 'textarea', 'details', 'dialog', 'template', 'output'} def check_to_sx(check, ref, elements=None, var_names=None): """Convert a parsed Chai check tuple to an SX assertion.""" typ, name, key, val = check # When checking a class on a bare tag name, upstream tests typically bind # that name to the element whose handler adds the class to itself. With # multiple top-level tags of the same kind, pick the `me` receiver. if (typ == 'class' and isinstance(key, str) and name in TAG_NAMES_FOR_REF and elements is not None and var_names is not None): recv = find_me_receiver(elements, var_names, name) r = recv if recv is not None else ref(name) else: r = ref(name) if typ == 'class' and val: return f'(assert (dom-has-class? {r} "{key}"))' elif typ == 'class' and not val: return f'(assert (not (dom-has-class? {r} "{key}")))' elif typ == 'innerHTML': escaped = key.replace('"', '\\"') if isinstance(key, str) else key return f'(assert= (dom-inner-html {r}) "{escaped}")' elif typ == 'textContent': escaped = key.replace('"', '\\"') return f'(assert= (dom-text-content {r}) "{escaped}")' elif typ == 'style': return f'(assert= (dom-get-style {r} "{key}") "{val}")' elif typ == 'attr': return f'(assert= (dom-get-attr {r} "{key}") "{val}")' elif typ == 'hasAttr' and val: return f'(assert (dom-has-attr? {r} "{key}"))' elif typ == 'hasAttr' and not val: return f'(assert (not (dom-has-attr? {r} "{key}")))' elif typ == 'computedStyle': return f';; SKIP computed style: {name}.{key}' elif typ == 'noParent': return f'(assert (nil? (dom-parent {r})))' elif typ == 'hasParent': return f'(assert (not (nil? (dom-parent {r}))))' elif typ == 'value': return f'(assert= (dom-get-prop {r} "value") "{key}")' else: return f';; SKIP check: {typ} {name}' # ── Playwright-style body parser (dev branch tests) ────────────── def selector_to_sx(selector, elements, var_names): """Convert a CSS selector from find('selector') to SX DOM lookup expression.""" selector = selector.strip("'\"") if selector.startswith('#'): # ID selector — might be compound like '#a output' if ' ' in selector: return f'(dom-query "{selector}")' return f'(dom-query-by-id "{selector[1:]}")' if selector.startswith('.'): return f'(dom-query "{selector}")' # Try tag match to a let-bound variable for i, el in enumerate(elements): if el['tag'] == selector and i < len(var_names): return var_names[i] # Fallback: query by tag return f'(dom-query "{selector}")' def parse_pw_args(args_str): """Parse Playwright assertion arguments like 'foo', "bar" or "name", "value".""" args = [] for m in re.finditer(r"""(['"])(.*?)\1""", args_str): args.append(m.group(2)) return args def pw_assertion_to_sx(target, negated, assert_type, args_str): """Convert a Playwright assertion to SX.""" args = parse_pw_args(args_str) if assert_type == 'toHaveText': val = args[0] if args else '' escaped = val.replace('\\', '\\\\').replace('"', '\\"') if negated: return f'(assert (!= (dom-text-content {target}) "{escaped}"))' return f'(assert= (dom-text-content {target}) "{escaped}")' elif assert_type == 'toHaveAttribute': attr_name = args[0] if args else '' if len(args) >= 2: attr_val = args[1].replace('\\', '\\\\').replace('"', '\\"') if negated: return f'(assert (!= (dom-get-attr {target} "{attr_name}") "{attr_val}"))' return f'(assert= (dom-get-attr {target} "{attr_name}") "{attr_val}")' else: if negated: return f'(assert (not (dom-has-attr? {target} "{attr_name}")))' return f'(assert (dom-has-attr? {target} "{attr_name}"))' elif assert_type == 'toHaveClass': cls = args[0] if args else '' if not cls: # Handle regex like /outer-clicked/ or /\bselected\b/ m = re.match(r'/(.+?)/', args_str) if m: cls = m.group(1) # Strip JS regex anchors/word-boundaries — the class name itself is # a bare ident, not a regex pattern. cls = re.sub(r'\\b', '', cls) cls = cls.strip('^$') if negated: return f'(assert (not (dom-has-class? {target} "{cls}")))' return f'(assert (dom-has-class? {target} "{cls}"))' elif assert_type == 'toHaveCSS': prop = args[0] if args else '' val = args[1] if len(args) >= 2 else '' # Browsers normalize colors to rgb()/rgba(); our DOM mock returns the # raw inline value. Map common rgb() forms back to keywords. rgb_to_name = { 'rgb(255, 0, 0)': 'red', 'rgb(0, 255, 0)': 'green', 'rgb(0, 0, 255)': 'blue', 'rgb(0, 0, 0)': 'black', 'rgb(255, 255, 255)': 'white', } if val in rgb_to_name: val = rgb_to_name[val] escaped = val.replace('\\', '\\\\').replace('"', '\\"') if negated: return f'(assert (!= (dom-get-style {target} "{prop}") "{escaped}"))' return f'(assert= (dom-get-style {target} "{prop}") "{escaped}")' elif assert_type == 'toHaveValue': val = args[0] if args else '' escaped = val.replace('\\', '\\\\').replace('"', '\\"') if negated: return f'(assert (!= (dom-get-prop {target} "value") "{escaped}"))' return f'(assert= (dom-get-prop {target} "value") "{escaped}")' elif assert_type == 'toBeVisible': if negated: return f'(assert (not (dom-visible? {target})))' return f'(assert (dom-visible? {target}))' elif assert_type == 'toBeHidden': if negated: return f'(assert (dom-visible? {target}))' return f'(assert (not (dom-visible? {target})))' elif assert_type == 'toBeChecked': if negated: return f'(assert (not (dom-get-prop {target} "checked")))' return f'(assert (dom-get-prop {target} "checked"))' return None def _body_statements(body): """Yield top-level statements from a JS test body, split on `;` at depth 0, respecting string/backtick/paren/brace nesting.""" depth, in_str, esc, buf = 0, None, False, [] for ch in body: if in_str: buf.append(ch) if esc: esc = False elif ch == '\\': esc = True elif ch == in_str: in_str = None continue if ch in ('"', "'", '`'): in_str = ch buf.append(ch) continue if ch in '([{': depth += 1 elif ch in ')]}': depth -= 1 if ch == ';' and depth == 0: s = ''.join(buf).strip() if s: yield s buf = [] else: buf.append(ch) last = ''.join(buf).strip() if last: yield last def _window_setup_ops(assign_body): """Parse `window.X = Y[; window.Z = W; ...]` into (name, sx_val) tuples.""" out = [] for substmt in split_top_level_chars(assign_body, ';'): sm = re.match(r'\s*window\.(\w+)\s*=\s*(.+?)\s*$', substmt, re.DOTALL) if not sm: continue sx_val = js_expr_to_sx(sm.group(2).strip()) if sx_val is not None: out.append((sm.group(1), sx_val)) return out def _hs_config_setup_ops(body): """Translate `_hyperscript.config.X = ...` assignments into SX ops. Recognises `defaultHideShowStrategy = "name"` and `hideShowStrategies = { NAME: fn }` for simple classList.add/remove-based strategies. Returns list of SX expr strings. Empty list means no recognised ops; caller should skip (don't drop the block).""" ops = [] # defaultHideShowStrategy = "name" for dm in re.finditer( r'_hyperscript\.config\.defaultHideShowStrategy\s*=\s*"([^"]+)"', body, ): ops.append(f'(hs-set-default-hide-strategy! "{dm.group(1)}")') for dm in re.finditer( r"_hyperscript\.config\.defaultHideShowStrategy\s*=\s*'([^']+)'", body, ): ops.append(f'(hs-set-default-hide-strategy! "{dm.group(1)}")') # delete _hyperscript.config.defaultHideShowStrategy if re.search(r'delete\s+_hyperscript\.config\.defaultHideShowStrategy', body): ops.append('(hs-set-default-hide-strategy! nil)') # hideShowStrategies = { NAME: function(op, element, arg) { IF-ELSE } } # Nested braces — locate the function body by manual brace-matching. sm = re.search( r'_hyperscript\.config\.hideShowStrategies\s*=\s*\{\s*' r'(\w+)\s*:\s*function\s*\(\s*\w+\s*,\s*\w+\s*,\s*\w+\s*\)\s*\{', body, ) if sm: name = sm.group(1) start = sm.end() depth = 1 i = start while i < len(body) and depth > 0: if body[i] == '{': depth += 1 elif body[i] == '}': depth -= 1 i += 1 fn_body = body[start:i - 1] if depth == 0 else '' hm = re.search( r'if\s*\(\s*\w+\s*==\s*"hide"\s*\)\s*\{\s*' r'\w+\.classList\.add\(\s*"([^"]+)"\s*\)\s*;?\s*\}\s*' r'else\s*\{\s*\w+\.classList\.remove\(\s*"([^"]+)"\s*\)\s*;?\s*\}', fn_body, re.DOTALL, ) if hm: cls = hm.group(1) ops.append( f'(hs-set-hide-strategies! {{:{name} ' f'(fn (op el arg) (if (= op "hide") (dom-add-class el "{cls}") (dom-remove-class el "{cls}")))}})' ) return ops def _extract_detail_expr(opts_src): """Extract `detail: ...` from an event options block like `, { detail: X }`. Returns an SX expression string, defaulting to `nil`.""" if not opts_src: return 'nil' # Plain string detail dm = re.search(r'detail:\s*"([^"]*)"', opts_src) if dm: return f'"{dm.group(1)}"' # Simple object detail: { k: "v", k2: "v2", ... } (string values only) dm = re.search(r'detail:\s*\{([^{}]*)\}', opts_src) if dm: pairs = re.findall(r'(\w+):\s*"([^"]*)"', dm.group(1)) if pairs: items = ' '.join(f':{k} "{v}"' for k, v in pairs) return '{' + items + '}' return 'nil' def parse_dev_body(body, elements, var_names): """Parse Playwright test body into ordered SX ops. Returns (pre_setups, ops) where: - pre_setups: list of (name, sx_val) for `window.X = Y` setups that appear BEFORE the first `html(...)` call; these should be emitted before element creation so activation can see them. - ops: ordered list of SX expression strings — setups, actions, and assertions interleaved in their original body order, starting after the first `html(...)` call. """ pre_setups = [] ops = [] seen_html = False def add_action(stmt): am = re.search( r"find\((['\"])(.+?)\1\)(?:\.(first|last)\(\)|\.nth\((\d+)\))?" r"\.(click|dispatchEvent|fill|check|uncheck|focus|selectOption)\(([^)]*)\)", stmt, ) if not am or 'expect' in stmt: return False selector = am.group(2) first_last = am.group(3) nth_idx = am.group(4) action_type = am.group(5) action_arg = am.group(6).strip("'\"") target = selector_to_sx(selector, elements, var_names) if nth_idx is not None: target = f'(nth (dom-query-all (dom-body) "{selector}") {nth_idx})' elif first_last == 'last': target = f'(let ((_all (dom-query-all (dom-body) "{selector}"))) (nth _all (- (len _all) 1)))' elif first_last == 'first': target = f'(nth (dom-query-all (dom-body) "{selector}") 0)' if action_type == 'click': ops.append(f'(dom-dispatch {target} "click" nil)') elif action_type == 'dispatchEvent': ops.append(f'(dom-dispatch {target} "{action_arg}" nil)') elif action_type == 'fill': escaped = action_arg.replace('\\', '\\\\').replace('"', '\\"') ops.append(f'(dom-set-prop {target} "value" "{escaped}")') ops.append(f'(dom-dispatch {target} "input" nil)') elif action_type == 'check': ops.append(f'(dom-set-prop {target} "checked" true)') ops.append(f'(dom-dispatch {target} "change" nil)') elif action_type == 'uncheck': ops.append(f'(dom-set-prop {target} "checked" false)') ops.append(f'(dom-dispatch {target} "change" nil)') elif action_type == 'focus': ops.append(f'(dom-focus {target})') elif action_type == 'selectOption': escaped = action_arg.replace('\\', '\\\\').replace('"', '\\"') ops.append(f'(dom-set-prop {target} "value" "{escaped}")') ops.append(f'(dom-dispatch {target} "change" nil)') return True def add_assertion(stmt): em = re.search( r"expect\(find\((['\"])(.+?)\1\)(?:\.(first|last)\(\)|\.nth\((\d+)\))?\)\.(not\.)?" r"(toHaveText|toHaveClass|toHaveCSS|toHaveAttribute|toHaveValue|toBeVisible|toBeHidden|toBeChecked)" r"\(((?:[^()]|\([^()]*\))*)\)", stmt, ) if not em: return False selector = em.group(2) first_last = em.group(3) nth_idx = em.group(4) negated = bool(em.group(5)) assert_type = em.group(6) args_str = em.group(7) target = selector_to_sx(selector, elements, var_names) if nth_idx is not None: target = f'(nth (dom-query-all (dom-body) "{selector}") {nth_idx})' elif first_last == 'last': target = f'(let ((_all (dom-query-all (dom-body) "{selector}"))) (nth _all (- (len _all) 1)))' elif first_last == 'first': target = f'(nth (dom-query-all (dom-body) "{selector}") 0)' sx = pw_assertion_to_sx(target, negated, assert_type, args_str) if sx: ops.append(sx) return True for stmt in _body_statements(body): stmt_na = re.sub(r'^(?:await\s+)+', '', stmt).strip() # html(...) — marks the DOM-built boundary. Setups after this go inline. if re.match(r'html\s*\(', stmt_na): seen_html = True continue # evaluate(() => window.X = Y) — single-expression window setup. m = re.match( r'evaluate\(\s*\(\)\s*=>\s*(window\.\w+\s*=\s*.+?)\s*\)\s*$', stmt_na, re.DOTALL, ) if m: for name, sx_val in _window_setup_ops(m.group(1)): if seen_html: ops.append(f'(host-set! (host-global "window") "{name}" {sx_val})') else: pre_setups.append((name, sx_val)) continue # evaluate(() => { window.X = Y; ... }) — block window setup. # Only `continue` if at least one window-setup was parsed, otherwise # fall through to other patterns that may match this `evaluate(...)`. m = re.match(r'evaluate\(\s*\(\)\s*=>\s*\{(.+)\}\s*\)\s*$', stmt_na, re.DOTALL) if m: setups_here = list(_window_setup_ops(m.group(1))) if setups_here: for name, sx_val in setups_here: if seen_html: ops.append(f'(host-set! (host-global "window") "{name}" {sx_val})') else: pre_setups.append((name, sx_val)) continue # _hyperscript.config.X = ... setups (hideShowStrategies etc.) hs_config_ops = _hs_config_setup_ops(m.group(1)) if hs_config_ops: for op_expr in hs_config_ops: if seen_html: ops.append(op_expr) else: pre_setups.append(('__hs_config__', op_expr)) continue # fall through # evaluate(() => _hyperscript.config.X = ...) single-line variant. m = re.match(r'evaluate\(\s*\(\)\s*=>\s*(_hyperscript\.config\..+?)\s*\)\s*$', stmt_na, re.DOTALL) if m: hs_config_ops = _hs_config_setup_ops(m.group(1)) if hs_config_ops: for op_expr in hs_config_ops: if seen_html: ops.append(op_expr) else: pre_setups.append(('__hs_config__', op_expr)) continue # evaluate(() => document.querySelector(SEL).innerHTML = VAL) — DOM reset. m = re.match( r"evaluate\(\s*\(\)\s*=>\s*document\.querySelector\(\s*(['\"])([^'\"]+)\1\s*\)" r"\.innerHTML\s*=\s*(['\"])(.*?)\3\s*\)\s*$", stmt_na, re.DOTALL, ) if m and seen_html: sel = re.sub(r'^#work-area\s+', '', m.group(2)) target = selector_to_sx(sel, elements, var_names) val = m.group(4).replace('\\', '\\\\').replace('"', '\\"') ops.append(f'(dom-set-inner-html {target} "{val}")') continue # evaluate(() => document.getElementById(ID).style.PROP = 'VALUE') # or document.querySelector(SEL).style.PROP = 'VALUE'. Used by resize # tests (cluster 26): writing style.width/height dispatches a synthetic # `resize` event via the mock style proxy. Accepts both arrow-expr # and block form: `() => expr` and `() => { expr; }`. Also accepts # the `page.evaluate` Playwright prefix. m = re.match( r"(?:page\.)?evaluate\(\s*\(\)\s*=>\s*\{?\s*" r"document\.(?:getElementById|querySelector)\(" r"\s*(['\"])([^'\"]+)\1\s*\)" r"\.style\.(\w+)\s*=\s*(['\"])(.*?)\4\s*;?\s*\}?\s*\)\s*$", stmt_na, re.DOTALL, ) if m and seen_html: sel = m.group(2) if sel and not sel.startswith(('#', '.', '[')): sel = '#' + sel sel = re.sub(r'^#work-area\s+', '', sel) target = selector_to_sx(sel, elements, var_names) prop = m.group(3) val = m.group(5).replace('\\', '\\\\').replace('"', '\\"') ops.append(f'(host-set! (host-get {target} "style") "{prop}" "{val}")') continue # clickAndReadStyle(evaluate, SEL, PROP) — upstream helper that # dispatches a click on SEL and returns its computed style[PROP]. # Materialize the click; downstream toHaveCSS assertions then test # the post-click state. The helper call may appear embedded in a # larger statement (e.g. `const x = await clickAndReadStyle(...)`) # so we use `search`, not `match`. m = re.search( r"clickAndReadStyle\(\s*\w+\s*,\s*(['\"])([^'\"]+)\1\s*,\s*['\"][^'\"]+['\"]\s*\)", stmt_na, ) if m and seen_html: sel = re.sub(r'^#work-area\s+', '', m.group(2)) target = selector_to_sx(sel, elements, var_names) ops.append(f'(dom-dispatch {target} "click" nil)') # Fall through so any trailing assertions in the same split # statement still get picked up. # evaluate(() => document.querySelector(SEL).click()) — dispatch click # on the matched element (bubbles so ancestors see it too). m = re.match( r"evaluate\(\s*\(\)\s*=>\s*document\.querySelector\(\s*(['\"])([^'\"]+)\1\s*\)" r"\.click\(\)\s*\)\s*$", stmt_na, re.DOTALL, ) if m and seen_html: sel = re.sub(r'^#work-area\s+', '', m.group(2)) target = selector_to_sx(sel, elements, var_names) ops.append(f'(dom-dispatch {target} "click" nil)') continue # evaluate(() => document.querySelector(SEL).dispatchEvent(new Event/CustomEvent(NAME…))) m = re.match( r"evaluate\(\s*\(\)\s*=>\s*document\.querySelector\(\s*(['\"])([^'\"]+)\1\s*\)" r"\.dispatchEvent\(\s*new\s+(?:Custom)?Event\(\s*(['\"])([^'\"]+)\3" r"(\s*,\s*\{.*\})?\s*\)\s*\)\s*\)\s*$", stmt_na, re.DOTALL, ) if m and seen_html: sel = re.sub(r'^#work-area\s+', '', m.group(2)) target = selector_to_sx(sel, elements, var_names) opts = m.group(5) or '' detail_expr = _extract_detail_expr(opts) ops.append(f'(dom-dispatch {target} "{m.group(4)}" {detail_expr})') continue # evaluate(() => { const e = new Event(NAME, {...}); document.querySelector(SEL).dispatchEvent(e); }) # Common upstream pattern for dispatching a non-bubbling click. m = re.match( r"evaluate\(\s*\(\)\s*=>\s*\{\s*" r"const\s+(\w+)\s*=\s*new\s+(?:Custom)?Event\(\s*(['\"])([^'\"]+)\2" r"(\s*,\s*\{[^}]*\})?\s*\)\s*;\s*" r"document\.querySelector\(\s*(['\"])([^'\"]+)\5\s*\)" r"\.dispatchEvent\(\s*\1\s*\)\s*;?\s*\}\s*\)\s*$", stmt_na, re.DOTALL, ) if m and seen_html: sel = re.sub(r'^#work-area\s+', '', m.group(6)) target = selector_to_sx(sel, elements, var_names) opts = m.group(4) or '' detail_expr = _extract_detail_expr(opts) ops.append(f'(dom-dispatch {target} "{m.group(3)}" {detail_expr})') continue # [const X = await ]evaluate(() => { const el = document.querySelector(SEL); el.dispatchEvent(new Event(NAME, ...)); return ... }) # Dispatches an event on a queried element and ignores the return value. # Stmt may have trailing un-split junk (`expect(...).toBe(...)`) since # body splitter only breaks on `;` and `})` doesn't always have one. m = re.match( r"(?:const\s+\w+\s*=\s*(?:await\s+)?)?" r"evaluate\(\s*\(\)\s*=>\s*\{\s*" r"const\s+(\w+)\s*=\s*document\.querySelector\(\s*(['\"])([^'\"]+)\2\s*\)\s*;?\s*" r"\1\.dispatchEvent\(\s*new\s+(?:Custom)?Event\(\s*(['\"])([^'\"]+)\4" r"(\s*,\s*\{[^}]*\})?\s*\)\s*\)\s*;?", stmt_na, re.DOTALL, ) if m and seen_html: sel = re.sub(r'^#work-area\s+', '', m.group(3)) target = selector_to_sx(sel, elements, var_names) opts = m.group(6) or '' detail_expr = _extract_detail_expr(opts) ops.append(f'(dom-dispatch {target} "{m.group(5)}" {detail_expr})') continue # evaluate(() => document.getElementById(ID).METHOD()) — generic # method dispatch (showModal, close, click, focus, blur, reset…). m = re.match( r"evaluate\(\s*\(\)\s*=>\s*document\.(?:getElementById|querySelector)\(" r"\s*(['\"])([^'\"]+)\1\s*\)" r"\.(click|showModal|close|focus|blur|reset|remove)\(\)\s*\)\s*$", stmt_na, re.DOTALL, ) if m and seen_html: sel = m.group(2) # getElementById wants bare id; querySelector wants #id or .cls if sel and not sel.startswith(('#', '.', '[')): sel = '#' + sel sel = re.sub(r'^#work-area\s+', '', sel) target = selector_to_sx(sel, elements, var_names) method = m.group(3) if method == 'click': ops.append(f'(dom-dispatch {target} "click" nil)') elif method == 'showModal': ops.append(f'(host-call {target} "showModal")') elif method == 'close': ops.append(f'(host-call {target} "close")') elif method == 'focus': ops.append(f'(dom-focus {target})') elif method == 'blur': ops.append(f'(host-call {target} "blur")') elif method == 'reset': ops.append(f'(host-call {target} "reset")') elif method == 'remove': ops.append(f'(host-call {target} "remove")') continue # evaluate(() => document.querySelector(SEL).classList.(add|remove|toggle)("X")) m = re.match( r'''evaluate\(\s*\(\)\s*=>\s*document\.querySelector\(\s*([\'"])([^\'"]+)\1\s*\)\.classList\.(add|remove|toggle)\(\s*([\'"])([^\'"]+)\4\s*\)\s*\)\s*$''', stmt_na, re.DOTALL, ) if m and seen_html: sel = m.group(2) sel = re.sub(r'^#work-area\s+', '', sel) target = selector_to_sx(sel, elements, var_names) op = m.group(3) cls = m.group(5) if op == 'add': ops.append(f'(dom-add-class {target} "{cls}")') elif op == 'remove': ops.append(f'(dom-remove-class {target} "{cls}")') elif op == 'toggle': ops.append(f'(if (dom-has-class? {target} "{cls}") (dom-remove-class {target} "{cls}") (dom-add-class {target} "{cls}"))') continue # evaluate(() => { var range = document.createRange(); # var textNode = document.getElementById(ID).firstChild; # range.setStart(textNode, N); range.setEnd(textNode, M); # window.getSelection().addRange(range); }) # -> set window.__test_selection to text slice m = re.search( r"document\.createRange\(\)[\s\S]*?document\.getElementById\(\s*['\"]([\w-]+)['\"]\s*\)[\s\S]*?setStart\([^,]+,\s*(\d+)\s*\)[\s\S]*?setEnd\([^,]+,\s*(\d+)\s*\)", stmt_na, ) if m and seen_html: el_id = m.group(1) start = int(m.group(2)) end = int(m.group(3)) # Find the element whose id matches, pull its inner text/HTML selected_text = None for el in elements: if el.get('id') == el_id: txt = el.get('inner') or '' selected_text = txt[start:end] break if selected_text is not None: ops.append(f'(host-set! (host-global "window") "__test_selection" "{selected_text}")') continue if not seen_html: continue if add_action(stmt_na): continue add_assertion(stmt_na) return pre_setups, ops # ── Test generation ─────────────────────────────────────────────── def _strip_hs_line_comments(s): """Strip `//…` and `--…` line comments outside HS string literals. HS has three string delimiters: single quotes, double quotes, and backticks (template strings). `https://…` inside a backtick must not be treated as a comment. """ out = [] i = 0 n = len(s) in_str = None # None | "'" | '"' | '`' while i < n: ch = s[i] if in_str is None: # Check for line-comment starters at depth 0. if ch == '/' and i + 1 < n and s[i + 1] == '/': # Skip to newline. while i < n and s[i] != '\n': i += 1 continue if ch == '-' and i + 1 < n and s[i + 1] == '-' and (i == 0 or s[i - 1].isspace()): while i < n and s[i] != '\n': i += 1 continue if ch in ("'", '"', '`'): in_str = ch out.append(ch) i += 1 else: if ch == '\\' and i + 1 < n: out.append(ch); out.append(s[i + 1]); i += 2 continue if ch == in_str: in_str = None out.append(ch) i += 1 return ''.join(out) def process_hs_val(hs_val): """Process a raw HS attribute value: collapse whitespace, insert 'then' separators.""" # Convert escaped newlines/tabs to real whitespace hs_val = hs_val.replace('\\n', '\n').replace('\\t', ' ') # Preserve escaped quotes (\" → placeholder), strip remaining backslashes, restore hs_val = hs_val.replace('\\"', '\x00QUOT\x00') hs_val = hs_val.replace('\\', '') hs_val = hs_val.replace('\x00QUOT\x00', '\\"') # Strip line comments BEFORE newline collapse — once newlines become `then`, # an unterminated `//` / ` --` comment would consume the rest of the input. # String-aware: `https://…` inside a backtick template must not be stripped. hs_val = _strip_hs_line_comments(hs_val) cmd_kws = r'(?:set|put|get|add|remove|toggle|hide|show|if|repeat|for|wait|send|trigger|log|call|take|throw|return|append|tell|go|halt|settle|increment|decrement|fetch|make|install|measure|empty|reset|swap|default|morph|render|scroll|focus|select|pick|beep!)' hs_val = re.sub(r'\s{2,}(?=' + cmd_kws + r'\b)', ' then ', hs_val) hs_val = re.sub(r'\s*[\n\r]\s*', ' then ', hs_val) hs_val = re.sub(r'\s+', ' ', hs_val) hs_val = re.sub(r'(then\s*)+then', 'then', hs_val) hs_val = re.sub(r'\bon (\w[\w.:+-]*) then\b', r'on \1 ', hs_val) hs_val = re.sub(r'(\bin (?:\[.*?\]|\S+)) then\b', r'\1 ', hs_val) hs_val = re.sub(r'\btimes then\b', 'times ', hs_val) hs_val = re.sub(r'\bend then\b', 'end ', hs_val) # `else then` is invalid HS — `else` already opens a new block. hs_val = re.sub(r'\belse then\b', 'else ', hs_val) # Same for `catch 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