HS runtime + generator: make, Values, toggle styles, scoped storage, array ops, fetch coercion, scripts in PW bodies
Runtime (lib/hyperscript/ + shared/static/wasm/sx/hs-*.sx):
- make: parser accepts `<tag.class#id/>` selectors and `from <expr>,…`; compiler
emits via scoped-set so `called <name>` 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 (<input>, <br>, …);
- 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 `<script type=text/hyperscript>` (flattened
across JS string-concat) under guard, exposing def blocks.
- Ordered ops for `run()`-style tests check window.obj.prop via new
_js_window_expr_to_sx; add DOM-constructing evaluate + _hyperscript
pattern for `as Values` tests (result.key[i].toBe(…)).
- js_val_to_sx handles backticks and escapes embedded quotes.
Net delta across suites:
- if 16→18, make 0→8, toggle 12→21, add 9→10, remove 11→16, put 29→31,
fetch 11→15, repeat 14→26, expressions/asExpression 20→25, set 27→28,
core/scoping 12→14, when 39→39 (no regression).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -193,11 +193,20 @@ with open(INPUT) as f:
|
||||
# ── HTML parsing ──────────────────────────────────────────────────
|
||||
|
||||
def extract_hs_scripts(html):
|
||||
"""Extract <script type='text/hyperscript'>...</script> content blocks."""
|
||||
"""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>",
|
||||
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 <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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
# `<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!)')
|
||||
|
||||
# `evaluate(() => window.X = Y)` setups — see generate_test_chai.
|
||||
for name, sx_val in extract_window_setups(test.get('body', '') or ''):
|
||||
# Pre-`html(...)` setups — emit before element creation so activation
|
||||
# (init handlers etc.) sees the expected globals.
|
||||
for name, sx_val in pre_setups:
|
||||
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)})')
|
||||
|
||||
@@ -974,7 +1108,10 @@ def js_val_to_sx(val):
|
||||
if val == 'false': return 'false'
|
||||
if val in ('null', 'undefined'): return 'nil'
|
||||
if val.startswith('"') or val.startswith("'"):
|
||||
return '"' + val.strip("\"'") + '"'
|
||||
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()
|
||||
@@ -1006,7 +1143,8 @@ def js_val_to_sx(val):
|
||||
float(val)
|
||||
return val
|
||||
except ValueError:
|
||||
return f'"{val}"'
|
||||
escaped = val.replace('\\', '\\\\').replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def split_top_level(s):
|
||||
@@ -1252,6 +1390,43 @@ def split_top_level_chars(s, sep_char):
|
||||
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
|
||||
@@ -1382,12 +1557,108 @@ def generate_eval_only_test(test, idx):
|
||||
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:
|
||||
decl_match = re.search(r'(?:var|let|const)\s+(\w+)', body)
|
||||
# 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
|
||||
@@ -1586,6 +1857,61 @@ def generate_eval_only_test(test, idx):
|
||||
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\(\)',
|
||||
|
||||
Reference in New Issue
Block a user