From 5b100cac176cac8d1a261fae8316a451e248f0a1 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 23 Apr 2026 16:08:07 +0000 Subject: [PATCH] HS runtime + generator: make, Values, toggle styles, scoped storage, array ops, fetch coercion, scripts in PW bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime (lib/hyperscript/ + shared/static/wasm/sx/hs-*.sx): - make: parser accepts `` selectors and `from ,…`; compiler emits via scoped-set so `called ` persists; `called $X` lands on window; runtime dispatches element vs host-new constructor by type. - Values: `x as Values` walks form inputs/selects/textareas, producing {name: value | [value,…]}; duplicates promote to array; multi-select and checkbox/radio handled. - toggle *display/*visibility/*opacity: paired with sensible inline defaults in the mock DOM so toggle flips block/visible/1 ↔ none/hidden/0. - add/remove/put at array: emit-set paths route list mutations back through the scoped binding; add hs-put-at! / hs-splice-at! / hs-dict-without. - remove OBJ.KEY / KEY of OBJ: rebuild dict via hs-dict-without and reassign, since SX dicts are copy-on-read across the bridge. - dom-set-data: use (host-new "Object") rather than (dict) so element-local storage actually persists between reads. - fetch: hs-fetch normalizes JSON/Object/Text/Response format aliases; compiler sets `the-result` when wrapping a fetch in the `let ((it …))` chain, and __get-cmd shares one evaluation via __hs-g. Mock DOM (tests/hs-run-filtered.js): - parseHTMLFragments accepts void elements (,
, …); - setAttribute tracks name/type/checked/selected/multiple; - select.options populated on appendChild; - insertAdjacentHTML parses fragments and inserts real El children into the parent so HS-activated handlers attach. Generator (tests/playwright/generate-sx-tests.py): - process_hs_val strips `//` / `--` line comments before newline→then collapse, and strips spurious `then` before else/end/catch/finally. - parse_dev_body interleaves window-setup ops and DOM resets between actions/assertions; pre-html setups still emit up front. - generate_test_pw compiles any ` content blocks.""" + """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"(.*?)", - html, re.DOTALL + flattened, re.DOTALL, ): scripts.append(m.group(1).strip()) return scripts @@ -723,75 +732,172 @@ def pw_assertion_to_sx(target, negated, assert_type, args_str): 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 parse_dev_body(body, elements, var_names): - """Parse Playwright test body to extract actions and post-action assertions. + """Parse Playwright test body into ordered SX ops. - Returns a single ordered list of SX expression strings (actions and assertions - interleaved in their original order). Pre-action assertions are skipped. + 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 = [] - found_first_action = False + seen_html = False - for line in body.split('\n'): - line = line.strip() + def add_action(stmt): + am = re.search( + r"find\((['\"])(.+?)\1\)(?:\.(?:first|last)\(\))?" + r"\.(click|dispatchEvent|fill|check|uncheck|focus|selectOption)\(([^)]*)\)", + stmt, + ) + if not am or 'expect' in stmt: + return False + selector = am.group(2) + action_type = am.group(3) + action_arg = am.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)') + return True - # 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( + def add_assertion(stmt): + em = re.search( r"expect\(find\((['\"])(.+?)\1\)(?:\.(?:first|last)\(\))?\)\.(not\.)?" r"(toHaveText|toHaveClass|toHaveCSS|toHaveAttribute|toHaveValue|toBeVisible|toBeHidden|toBeChecked)" r"\(((?:[^()]|\([^()]*\))*)\)", - line + stmt, ) - 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) + if not em: + return False + selector = em.group(2) + negated = bool(em.group(3)) + assert_type = em.group(4) + args_str = em.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) + 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 - return ops + # 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. + m = re.match(r'evaluate\(\s*\(\)\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(() => 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 + + if not seen_html: + continue + if add_action(stmt_na): + continue + add_assertion(stmt_na) + + return pre_setups, ops # ── Test generation ─────────────────────────────────────────────── @@ -804,6 +910,10 @@ def process_hs_val(hs_val): 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) @@ -817,6 +927,12 @@ def process_hs_val(hs_val): 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() @@ -945,16 +1061,34 @@ def generate_test_pw(test, elements, var_names, idx): if test['name'] in SKIP_TEST_NAMES: return emit_skip_test(test) - ops = parse_dev_body(test['body'], elements, var_names) + pre_setups, ops = parse_dev_body(test['body'], elements, var_names) + + # `