#!/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.""" scripts = [] for m in re.finditer( r"(.*?)", html, 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(' | ', '') # Fix escaped attribute delimiters from JSON extraction (\" → ") html = html.replace('\\"', '"') 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/ m = re.match(r'/(.+?)/', args_str) if m: cls = m.group(1) 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 '' 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 parse_dev_body(body, elements, var_names): """Parse Playwright test body to extract actions and post-action assertions. Returns a single ordered list of SX expression strings (actions and assertions interleaved in their original order). Pre-action assertions are skipped. """ ops = [] found_first_action = False for line in body.split('\n'): line = line.strip() # Skip comments if line.startswith('//'): continue # Action: find('selector')[.first()/.last()].click/dispatchEvent/fill/check/uncheck/focus() m = re.search(r"find\((['\"])(.+?)\1\)(?:\.(?:first|last)\(\))?\.(click|dispatchEvent|fill|check|uncheck|focus|selectOption)\(([^)]*)\)", line) if m and 'expect' not in line: found_first_action = True selector = m.group(2) action_type = m.group(3) action_arg = m.group(4).strip("'\"") target = selector_to_sx(selector, elements, var_names) 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)') continue # Skip lines before first action (pre-checks, setup) if not found_first_action: continue # Assertion: expect(find('selector')[.first()/.last()]).[not.]toHaveText("value") m = re.search( r"expect\(find\((['\"])(.+?)\1\)(?:\.(?:first|last)\(\))?\)\.(not\.)?" r"(toHaveText|toHaveClass|toHaveCSS|toHaveAttribute|toHaveValue|toBeVisible|toBeHidden|toBeChecked)" r"\(([^)]*)\)", line ) if m: selector = m.group(2) negated = bool(m.group(3)) assert_type = m.group(4) args_str = m.group(5) target = selector_to_sx(selector, elements, var_names) sx = pw_assertion_to_sx(target, negated, assert_type, args_str) if sx: ops.append(sx) continue return 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', '\\"') 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) 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