eval-hs helper + generator fixes — 304/941 (32%)
Added eval-hs: compile and evaluate HS expressions/commands, used by conformance-dev tests. Smart wrapping: adds 'return' prefix for expressions, leaves commands (set/put/get/then/return) as-is. Fixed generator ref() to use context-aware variable mapping. 304/941 with the user's conformance-dev.sx tests included (110 new). Failure breakdown: 111 stubs, 74 "bar" (eval errors), 51 assertion failures, 30 eval-only stubs, 24 undefined "live", 18 parser errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,16 @@ Generate spec/tests/test-hyperscript-behavioral.sx from upstream _hyperscript te
|
|||||||
Reads spec/tests/hyperscript-upstream-tests.json and produces SX deftest forms
|
Reads spec/tests/hyperscript-upstream-tests.json and produces SX deftest forms
|
||||||
that run in the Playwright sandbox with real DOM.
|
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
|
Usage: python3 tests/playwright/generate-sx-tests.py
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
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')
|
INPUT = os.path.join(PROJECT_ROOT, 'spec/tests/hyperscript-upstream-tests.json')
|
||||||
@@ -18,6 +23,8 @@ OUTPUT = os.path.join(PROJECT_ROOT, 'spec/tests/test-hyperscript-behavioral.sx')
|
|||||||
with open(INPUT) as f:
|
with open(INPUT) as f:
|
||||||
raw_tests = json.load(f)
|
raw_tests = json.load(f)
|
||||||
|
|
||||||
|
# ── HTML parsing ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def parse_html(html):
|
def parse_html(html):
|
||||||
"""Parse HTML into list of element dicts.
|
"""Parse HTML into list of element dicts.
|
||||||
Uses Python's html.parser for reliability with same-tag siblings."""
|
Uses Python's html.parser for reliability with same-tag siblings."""
|
||||||
@@ -56,8 +63,29 @@ def parse_html(html):
|
|||||||
Parser().feed(html)
|
Parser().feed(html)
|
||||||
return elements
|
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):
|
def parse_action(action, ref):
|
||||||
"""Convert upstream action to SX. Returns list of SX expressions."""
|
"""Convert upstream Chai-style action to SX. Returns list of SX expressions."""
|
||||||
if not action or action == '(see body)':
|
if not action or action == '(see body)':
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -97,6 +125,7 @@ def parse_action(action, ref):
|
|||||||
|
|
||||||
return exprs
|
return exprs
|
||||||
|
|
||||||
|
|
||||||
def parse_checks(check):
|
def parse_checks(check):
|
||||||
"""Convert Chai assertions to SX assert forms. Returns list of SX expressions.
|
"""Convert Chai assertions to SX assert forms. Returns list of SX expressions.
|
||||||
Only keeps post-action assertions (last occurrence per expression)."""
|
Only keeps post-action assertions (last occurrence per expression)."""
|
||||||
@@ -109,7 +138,6 @@ def parse_checks(check):
|
|||||||
if not part:
|
if not part:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Pattern: var.classList.contains("cls").should.equal(bool)
|
|
||||||
m = re.match(r'(\w+)\.classList\.contains\("([^"]+)"\)\.should\.equal\((true|false)\)', part)
|
m = re.match(r'(\w+)\.classList\.contains\("([^"]+)"\)\.should\.equal\((true|false)\)', part)
|
||||||
if m:
|
if m:
|
||||||
name, cls, expected = m.group(1), m.group(2), m.group(3)
|
name, cls, expected = m.group(1), m.group(2), m.group(3)
|
||||||
@@ -119,49 +147,41 @@ def parse_checks(check):
|
|||||||
all_checks.append(('class', name, cls, False))
|
all_checks.append(('class', name, cls, False))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Pattern: var.innerHTML.should.equal("value")
|
|
||||||
m = re.match(r'(\w+)\.innerHTML\.should\.equal\("([^"]*)"\)', part)
|
m = re.match(r'(\w+)\.innerHTML\.should\.equal\("([^"]*)"\)', part)
|
||||||
if m:
|
if m:
|
||||||
all_checks.append(('innerHTML', m.group(1), m.group(2), None))
|
all_checks.append(('innerHTML', m.group(1), m.group(2), None))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Pattern: var.innerHTML.should.equal(value) — non-string
|
|
||||||
m = re.match(r'(\w+)\.innerHTML\.should\.equal\((.+)\)', part)
|
m = re.match(r'(\w+)\.innerHTML\.should\.equal\((.+)\)', part)
|
||||||
if m:
|
if m:
|
||||||
all_checks.append(('innerHTML', m.group(1), m.group(2), None))
|
all_checks.append(('innerHTML', m.group(1), m.group(2), None))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Pattern: var.textContent.should.equal("value")
|
|
||||||
m = re.match(r'(\w+)\.textContent\.should\.equal\("([^"]*)"\)', part)
|
m = re.match(r'(\w+)\.textContent\.should\.equal\("([^"]*)"\)', part)
|
||||||
if m:
|
if m:
|
||||||
all_checks.append(('textContent', m.group(1), m.group(2), None))
|
all_checks.append(('textContent', m.group(1), m.group(2), None))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Pattern: var.style.prop.should.equal("value")
|
|
||||||
m = re.match(r'(\w+)\.style\.(\w+)\.should\.equal\("([^"]*)"\)', part)
|
m = re.match(r'(\w+)\.style\.(\w+)\.should\.equal\("([^"]*)"\)', part)
|
||||||
if m:
|
if m:
|
||||||
all_checks.append(('style', m.group(1), m.group(2), m.group(3)))
|
all_checks.append(('style', m.group(1), m.group(2), m.group(3)))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Pattern: var.getAttribute("name").should.equal("value")
|
|
||||||
m = re.match(r'(\w+)\.getAttribute\("([^"]+)"\)\.should\.equal\("([^"]*)"\)', part)
|
m = re.match(r'(\w+)\.getAttribute\("([^"]+)"\)\.should\.equal\("([^"]*)"\)', part)
|
||||||
if m:
|
if m:
|
||||||
all_checks.append(('attr', m.group(1), m.group(2), m.group(3)))
|
all_checks.append(('attr', m.group(1), m.group(2), m.group(3)))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Pattern: var.hasAttribute("name").should.equal(bool)
|
|
||||||
m = re.match(r'(\w+)\.hasAttribute\("([^"]+)"\)\.should\.equal\((true|false)\)', part)
|
m = re.match(r'(\w+)\.hasAttribute\("([^"]+)"\)\.should\.equal\((true|false)\)', part)
|
||||||
if m:
|
if m:
|
||||||
all_checks.append(('hasAttr', m.group(1), m.group(2), m.group(3) == 'true'))
|
all_checks.append(('hasAttr', m.group(1), m.group(2), m.group(3) == 'true'))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Pattern: getComputedStyle(var).prop.should.equal("value")
|
|
||||||
m = re.match(r'getComputedStyle\((\w+)\)\.(\w+)\.should\.equal\("([^"]*)"\)', part)
|
m = re.match(r'getComputedStyle\((\w+)\)\.(\w+)\.should\.equal\("([^"]*)"\)', part)
|
||||||
if m:
|
if m:
|
||||||
all_checks.append(('computedStyle', m.group(1), m.group(2), m.group(3)))
|
all_checks.append(('computedStyle', m.group(1), m.group(2), m.group(3)))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Pattern: var.parentElement assert
|
|
||||||
m = re.match(r'assert\.isNull\((\w+)\.parentElement\)', part)
|
m = re.match(r'assert\.isNull\((\w+)\.parentElement\)', part)
|
||||||
if m:
|
if m:
|
||||||
all_checks.append(('noParent', m.group(1), None, None))
|
all_checks.append(('noParent', m.group(1), None, None))
|
||||||
@@ -171,13 +191,11 @@ def parse_checks(check):
|
|||||||
all_checks.append(('hasParent', m.group(1), None, None))
|
all_checks.append(('hasParent', m.group(1), None, None))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Pattern: var.value.should.equal("value") — input value
|
|
||||||
m = re.match(r'(\w+)\.value\.should\.equal\("([^"]*)"\)', part)
|
m = re.match(r'(\w+)\.value\.should\.equal\("([^"]*)"\)', part)
|
||||||
if m:
|
if m:
|
||||||
all_checks.append(('value', m.group(1), m.group(2), None))
|
all_checks.append(('value', m.group(1), m.group(2), None))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip unrecognized
|
|
||||||
all_checks.append(('skip', part[:60], None, None))
|
all_checks.append(('skip', part[:60], None, None))
|
||||||
|
|
||||||
# Deduplicate: keep last per (type, name, key)
|
# Deduplicate: keep last per (type, name, key)
|
||||||
@@ -188,16 +206,11 @@ def parse_checks(check):
|
|||||||
|
|
||||||
return list(seen.values())
|
return list(seen.values())
|
||||||
|
|
||||||
def make_ref_fn(elements, var_names):
|
|
||||||
"""Create a ref function that maps upstream JS variable names to SX let-bound variables.
|
|
||||||
|
|
||||||
Upstream patterns:
|
def make_ref_fn(elements, var_names):
|
||||||
- 'div', 'form' etc. → last element with that tag (the make() return value)
|
"""Create a ref function that maps upstream JS variable names to SX let-bound variables."""
|
||||||
- 'd1', 'bar', 'p1' etc. → element with that ID, or last element if no ID match
|
tag_to_var = {}
|
||||||
"""
|
id_to_var = {}
|
||||||
# Build mappings
|
|
||||||
tag_to_var = {} # tag -> last var with that tag
|
|
||||||
id_to_var = {} # id -> var
|
|
||||||
last_var = var_names[-1] if var_names else '_el-div'
|
last_var = var_names[-1] if var_names else '_el-div'
|
||||||
|
|
||||||
for i, el in enumerate(elements):
|
for i, el in enumerate(elements):
|
||||||
@@ -206,25 +219,23 @@ def make_ref_fn(elements, var_names):
|
|||||||
id_to_var[el['id']] = var_names[i]
|
id_to_var[el['id']] = var_names[i]
|
||||||
|
|
||||||
tags = {'div', 'form', 'button', 'input', 'span', 'p', 'a', 'section',
|
tags = {'div', 'form', 'button', 'input', 'span', 'p', 'a', 'section',
|
||||||
'ul', 'li', 'select', 'textarea', 'details', 'dialog', 'template'}
|
'ul', 'li', 'select', 'textarea', 'details', 'dialog', 'template',
|
||||||
|
'output'}
|
||||||
|
|
||||||
def ref(name):
|
def ref(name):
|
||||||
if name in tags:
|
if name in tags:
|
||||||
return tag_to_var.get(name, last_var)
|
return tag_to_var.get(name, last_var)
|
||||||
if name in id_to_var:
|
if name in id_to_var:
|
||||||
return id_to_var[name]
|
return id_to_var[name]
|
||||||
# make() return variable pattern: d1, d2, div1, btn1, etc.
|
|
||||||
# These refer to the last element created by make()
|
|
||||||
if re.match(r'^[a-z]+\d*$', name) and len(elements) > 0:
|
if re.match(r'^[a-z]+\d*$', name) and len(elements) > 0:
|
||||||
# If there's an element with this as id, use dom-query-by-id
|
|
||||||
# Otherwise it's the make() return var — use last element
|
|
||||||
return last_var
|
return last_var
|
||||||
return f'(dom-query-by-id "{name}")'
|
return f'(dom-query-by-id "{name}")'
|
||||||
|
|
||||||
return ref
|
return ref
|
||||||
|
|
||||||
|
|
||||||
def check_to_sx(check, ref):
|
def check_to_sx(check, ref):
|
||||||
"""Convert a parsed check tuple to an SX assertion."""
|
"""Convert a parsed Chai check tuple to an SX assertion."""
|
||||||
typ, name, key, val = check
|
typ, name, key, val = check
|
||||||
r = ref(name)
|
r = ref(name)
|
||||||
if typ == 'class' and val:
|
if typ == 'class' and val:
|
||||||
@@ -246,7 +257,6 @@ def check_to_sx(check, ref):
|
|||||||
elif typ == 'hasAttr' and not val:
|
elif typ == 'hasAttr' and not val:
|
||||||
return f'(assert (not (dom-has-attr? {r} "{key}")))'
|
return f'(assert (not (dom-has-attr? {r} "{key}")))'
|
||||||
elif typ == 'computedStyle':
|
elif typ == 'computedStyle':
|
||||||
# Can't reliably test computed styles in sandbox
|
|
||||||
return f';; SKIP computed style: {name}.{key}'
|
return f';; SKIP computed style: {name}.{key}'
|
||||||
elif typ == 'noParent':
|
elif typ == 'noParent':
|
||||||
return f'(assert (nil? (dom-parent {r})))'
|
return f'(assert (nil? (dom-parent {r})))'
|
||||||
@@ -257,50 +267,161 @@ def check_to_sx(check, ref):
|
|||||||
else:
|
else:
|
||||||
return f';; SKIP check: {typ} {name}'
|
return f';; SKIP check: {typ} {name}'
|
||||||
|
|
||||||
def generate_test(test, idx):
|
|
||||||
"""Generate SX deftest for an upstream test."""
|
|
||||||
elements = parse_html(test['html'])
|
|
||||||
|
|
||||||
if not elements and not test.get('html', '').strip():
|
# ── Playwright-style body parser (dev branch tests) ──────────────
|
||||||
# eval-only test — no HTML at all
|
|
||||||
return None # Will get a failing stub
|
|
||||||
if not elements:
|
|
||||||
return None # HTML exists but couldn't parse it
|
|
||||||
|
|
||||||
lines = []
|
def selector_to_sx(selector, elements, var_names):
|
||||||
lines.append(f' (deftest "{test["name"]}"')
|
"""Convert a CSS selector from find('selector') to SX DOM lookup expression."""
|
||||||
lines.append(' (hs-cleanup!)')
|
selector = selector.strip("'\"")
|
||||||
|
if selector.startswith('#'):
|
||||||
# Assign unique variable names to each element
|
# ID selector — might be compound like '#a output'
|
||||||
var_names = []
|
if ' ' in selector:
|
||||||
used_names = set()
|
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):
|
for i, el in enumerate(elements):
|
||||||
if el['id']:
|
if el['tag'] == selector and i < len(var_names):
|
||||||
var = f'_el-{el["id"]}'
|
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 (!= "{escaped}" (dom-text-content {target})))'
|
||||||
|
return f'(assert= "{escaped}" (dom-text-content {target}))'
|
||||||
|
|
||||||
|
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 (!= "{attr_val}" (dom-get-attr {target} "{attr_name}")))'
|
||||||
|
return f'(assert= "{attr_val}" (dom-get-attr {target} "{attr_name}"))'
|
||||||
else:
|
else:
|
||||||
var = f'_el-{el["tag"]}'
|
if negated:
|
||||||
# Ensure uniqueness
|
return f'(assert (not (dom-has-attr? {target} "{attr_name}")))'
|
||||||
if var in used_names:
|
return f'(assert (dom-has-attr? {target} "{attr_name}"))'
|
||||||
var = f'{var}{i}'
|
|
||||||
used_names.add(var)
|
|
||||||
var_names.append(var)
|
|
||||||
|
|
||||||
# Create ref function with element context
|
elif assert_type == 'toHaveClass':
|
||||||
ref = make_ref_fn(elements, var_names)
|
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}"))'
|
||||||
|
|
||||||
# Parse actions and checks with context-aware ref
|
elif assert_type == 'toHaveCSS':
|
||||||
actions = parse_action(test['action'], ref)
|
prop = args[0] if args else ''
|
||||||
checks = parse_checks(test['check'])
|
val = args[1] if len(args) >= 2 else ''
|
||||||
|
escaped = val.replace('\\', '\\\\').replace('"', '\\"')
|
||||||
|
if negated:
|
||||||
|
return f'(assert (!= "{escaped}" (dom-get-style {target} "{prop}")))'
|
||||||
|
return f'(assert= "{escaped}" (dom-get-style {target} "{prop}"))'
|
||||||
|
|
||||||
# Create elements
|
elif assert_type == 'toHaveValue':
|
||||||
bindings = []
|
val = args[0] if args else ''
|
||||||
for i, el in enumerate(elements):
|
escaped = val.replace('\\', '\\\\').replace('"', '\\"')
|
||||||
bindings.append(f'({var_names[i]} (dom-create-element "{el["tag"]}"))')
|
if negated:
|
||||||
|
return f'(assert (!= "{escaped}" (dom-get-prop {target} "value")))'
|
||||||
|
return f'(assert= "{escaped}" (dom-get-prop {target} "value"))'
|
||||||
|
|
||||||
# Build let block
|
elif assert_type == 'toBeVisible':
|
||||||
lines.append(f' (let ({" ".join(bindings)})')
|
if negated:
|
||||||
|
return f'(assert (not (dom-visible? {target})))'
|
||||||
|
return f'(assert (dom-visible? {target}))'
|
||||||
|
|
||||||
# Set attributes and append
|
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').click() or .dispatchEvent('event')
|
||||||
|
m = re.search(r"find\((['\"])(.+?)\1\)\.(click|dispatchEvent)\(([^)]*)\)", 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)')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip lines before first action (pre-checks, setup)
|
||||||
|
if not found_first_action:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Assertion: expect(find('selector')).[not.]toHaveText("value")
|
||||||
|
m = re.search(
|
||||||
|
r"expect\(find\((['\"])(.+?)\1\)\)\.(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 emit_element_setup(lines, elements, var_names):
|
||||||
|
"""Emit SX for creating elements, setting attributes, appending to body, and activating."""
|
||||||
for i, el in enumerate(elements):
|
for i, el in enumerate(elements):
|
||||||
var = var_names[i]
|
var = var_names[i]
|
||||||
|
|
||||||
@@ -312,12 +433,12 @@ def generate_test(test, idx):
|
|||||||
hs_val = el['hs']
|
hs_val = el['hs']
|
||||||
hs_val = hs_val.replace('\\', '').replace('\n', ' ').replace('\t', ' ').strip()
|
hs_val = hs_val.replace('\\', '').replace('\n', ' ').replace('\t', ' ').strip()
|
||||||
if not hs_val:
|
if not hs_val:
|
||||||
|
lines.append(f' (dom-append (dom-body) {var})')
|
||||||
continue
|
continue
|
||||||
# Skip malformed values (HTML parser artifacts starting/ending with quotes)
|
|
||||||
if hs_val.startswith('"') or hs_val.endswith('"'):
|
if hs_val.startswith('"') or hs_val.endswith('"'):
|
||||||
lines.append(f' ;; HS source has bare quotes — HTML parse artifact')
|
lines.append(f' ;; HS source has bare quotes — HTML parse artifact')
|
||||||
|
lines.append(f' (dom-append (dom-body) {var})')
|
||||||
continue
|
continue
|
||||||
# Escape for SX double-quoted string
|
|
||||||
hs_escaped = hs_val.replace('\\', '\\\\').replace('"', '\\"')
|
hs_escaped = hs_val.replace('\\', '\\\\').replace('"', '\\"')
|
||||||
lines.append(f' (dom-set-attr {var} "_" "{hs_escaped}")')
|
lines.append(f' (dom-set-attr {var} "_" "{hs_escaped}")')
|
||||||
for aname, aval in el['attrs'].items():
|
for aname, aval in el['attrs'].items():
|
||||||
@@ -330,24 +451,74 @@ def generate_test(test, idx):
|
|||||||
if el['hs']:
|
if el['hs']:
|
||||||
lines.append(f' (hs-activate! {var})')
|
lines.append(f' (hs-activate! {var})')
|
||||||
|
|
||||||
# Actions
|
|
||||||
|
def generate_test_chai(test, elements, var_names, idx):
|
||||||
|
"""Generate SX deftest using Chai-style action/check fields."""
|
||||||
|
ref = make_ref_fn(elements, var_names)
|
||||||
|
actions = parse_action(test['action'], ref)
|
||||||
|
checks = parse_checks(test['check'])
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append(f' (deftest "{test["name"]}"')
|
||||||
|
lines.append(' (hs-cleanup!)')
|
||||||
|
|
||||||
|
bindings = [f'({var_names[i]} (dom-create-element "{el["tag"]}"))' for i, el in enumerate(elements)]
|
||||||
|
lines.append(f' (let ({" ".join(bindings)})')
|
||||||
|
|
||||||
|
emit_element_setup(lines, elements, var_names)
|
||||||
|
|
||||||
for action in actions:
|
for action in actions:
|
||||||
lines.append(f' {action}')
|
lines.append(f' {action}')
|
||||||
|
|
||||||
# Assertions
|
|
||||||
for check in checks:
|
for check in checks:
|
||||||
sx = check_to_sx(check, ref)
|
sx = check_to_sx(check, ref)
|
||||||
lines.append(f' {sx}')
|
lines.append(f' {sx}')
|
||||||
|
|
||||||
lines.append(' ))') # close let + deftest
|
lines.append(' ))')
|
||||||
|
|
||||||
return '\n'.join(lines)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
# Generate the file
|
def generate_test_pw(test, elements, var_names, idx):
|
||||||
|
"""Generate SX deftest using Playwright-style body field."""
|
||||||
|
ops = parse_dev_body(test['body'], elements, var_names)
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append(f' (deftest "{test["name"]}"')
|
||||||
|
lines.append(' (hs-cleanup!)')
|
||||||
|
|
||||||
|
bindings = [f'({var_names[i]} (dom-create-element "{el["tag"]}"))' for i, el in enumerate(elements)]
|
||||||
|
lines.append(f' (let ({" ".join(bindings)})')
|
||||||
|
|
||||||
|
emit_element_setup(lines, elements, var_names)
|
||||||
|
|
||||||
|
for op in ops:
|
||||||
|
lines.append(f' {op}')
|
||||||
|
|
||||||
|
lines.append(' ))')
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_test(test, idx):
|
||||||
|
"""Generate SX deftest for an upstream test. Dispatches to Chai or PW parser."""
|
||||||
|
elements = parse_html(test['html'])
|
||||||
|
|
||||||
|
if not elements and not test.get('html', '').strip():
|
||||||
|
return None
|
||||||
|
if not elements:
|
||||||
|
return None
|
||||||
|
|
||||||
|
var_names = assign_var_names(elements)
|
||||||
|
|
||||||
|
if test.get('body'):
|
||||||
|
return generate_test_pw(test, elements, var_names, idx)
|
||||||
|
else:
|
||||||
|
return generate_test_chai(test, elements, var_names, idx)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Output generation ─────────────────────────────────────────────
|
||||||
|
|
||||||
output = []
|
output = []
|
||||||
output.append(';; Hyperscript behavioral tests — auto-generated from upstream _hyperscript test suite')
|
output.append(';; Hyperscript behavioral tests — auto-generated from upstream _hyperscript test suite')
|
||||||
output.append(';; Source: spec/tests/hyperscript-upstream-tests.json (346 tests)')
|
output.append(f';; Source: spec/tests/hyperscript-upstream-tests.json ({len(raw_tests)} tests, v0.9.14 + dev)')
|
||||||
output.append(';; DO NOT EDIT — regenerate with: python3 tests/playwright/generate-sx-tests.py')
|
output.append(';; DO NOT EDIT — regenerate with: python3 tests/playwright/generate-sx-tests.py')
|
||||||
output.append('')
|
output.append('')
|
||||||
output.append(';; ── Test helpers ──────────────────────────────────────────────────')
|
output.append(';; ── Test helpers ──────────────────────────────────────────────────')
|
||||||
@@ -366,7 +537,6 @@ output.append(' (dom-set-inner-html (dom-body) "")))')
|
|||||||
output.append('')
|
output.append('')
|
||||||
|
|
||||||
# Group by category
|
# Group by category
|
||||||
from collections import OrderedDict
|
|
||||||
categories = OrderedDict()
|
categories = OrderedDict()
|
||||||
for t in raw_tests:
|
for t in raw_tests:
|
||||||
cat = t['category']
|
cat = t['category']
|
||||||
@@ -376,28 +546,38 @@ for t in raw_tests:
|
|||||||
|
|
||||||
total = 0
|
total = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
generated_counts = {} # cat -> (generated, stubbed)
|
||||||
for cat, tests in categories.items():
|
for cat, tests in categories.items():
|
||||||
output.append(f';; ── {cat} ({len(tests)} tests) ──')
|
output.append(f';; ── {cat} ({len(tests)} tests) ──')
|
||||||
output.append(f'(defsuite "hs-upstream-{cat}"')
|
output.append(f'(defsuite "hs-upstream-{cat}"')
|
||||||
|
|
||||||
|
cat_gen = 0
|
||||||
|
cat_stub = 0
|
||||||
for i, t in enumerate(tests):
|
for i, t in enumerate(tests):
|
||||||
sx = generate_test(t, i)
|
sx = generate_test(t, i)
|
||||||
if sx:
|
if sx:
|
||||||
output.append(sx)
|
output.append(sx)
|
||||||
total += 1
|
total += 1
|
||||||
|
cat_gen += 1
|
||||||
else:
|
else:
|
||||||
# Generate a failing test stub so the gap is visible
|
|
||||||
safe_name = t['name'].replace('"', "'")
|
safe_name = t['name'].replace('"', "'")
|
||||||
output.append(f' (deftest "{safe_name}"')
|
output.append(f' (deftest "{safe_name}"')
|
||||||
output.append(f' (error "NOT IMPLEMENTED: test HTML could not be parsed into SX"))')
|
output.append(f' (error "NOT IMPLEMENTED: test HTML could not be parsed into SX"))')
|
||||||
total += 1
|
total += 1
|
||||||
|
cat_stub += 1
|
||||||
|
|
||||||
output.append(')') # close defsuite
|
output.append(')')
|
||||||
output.append('')
|
output.append('')
|
||||||
|
generated_counts[cat] = (cat_gen, cat_stub)
|
||||||
|
|
||||||
with open(OUTPUT, 'w') as f:
|
with open(OUTPUT, 'w') as f:
|
||||||
f.write('\n'.join(output))
|
f.write('\n'.join(output))
|
||||||
|
|
||||||
print(f'Generated {total} tests ({skipped} skipped) -> {OUTPUT}')
|
# Report
|
||||||
for cat, tests in categories.items():
|
has_body = sum(1 for t in raw_tests if t.get('body'))
|
||||||
print(f' {cat}: {len(tests)}')
|
print(f'Generated {total} tests -> {OUTPUT}')
|
||||||
|
print(f' Source: {len(raw_tests)} tests ({len(raw_tests) - has_body} Chai-style, {has_body} Playwright-style)')
|
||||||
|
print(f' Categories: {len(categories)}')
|
||||||
|
for cat, (gen, stub) in generated_counts.items():
|
||||||
|
marker = '' if stub == 0 else f' ({stub} stubs)'
|
||||||
|
print(f' {cat}: {gen}{marker}')
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function getModuleSrc(mod) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache test file sources
|
// Cache test file sources
|
||||||
const TEST_FILES = ['spec/harness.sx', 'spec/tests/test-framework.sx', 'spec/tests/test-hyperscript-behavioral.sx'];
|
const TEST_FILES = ['spec/harness.sx', 'spec/tests/test-framework.sx', 'spec/tests/test-hyperscript-behavioral.sx', 'spec/tests/test-hyperscript-conformance-dev.sx'];
|
||||||
const TEST_FILE_CACHE = {};
|
const TEST_FILE_CACHE = {};
|
||||||
for (const f of TEST_FILES) {
|
for (const f of TEST_FILES) {
|
||||||
TEST_FILE_CACHE[f] = fs.readFileSync(path.join(PROJECT_ROOT, f), 'utf8');
|
TEST_FILE_CACHE[f] = fs.readFileSync(path.join(PROJECT_ROOT, f), 'utf8');
|
||||||
@@ -111,7 +111,7 @@ async function bootSandbox(page) {
|
|||||||
}
|
}
|
||||||
await page.evaluate(() => { if (window.SxKernel.endModuleLoad) window.SxKernel.endModuleLoad(); });
|
await page.evaluate(() => { if (window.SxKernel.endModuleLoad) window.SxKernel.endModuleLoad(); });
|
||||||
|
|
||||||
// Deferred test registration
|
// Deferred test registration + helpers
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const K = window.SxKernel;
|
const K = window.SxKernel;
|
||||||
K.eval('(define _test-registry (list))');
|
K.eval('(define _test-registry (list))');
|
||||||
@@ -127,6 +127,18 @@ async function bootSandbox(page) {
|
|||||||
K.eval(`(define report-fail (fn (name error)
|
K.eval(`(define report-fail (fn (name error)
|
||||||
(let ((i (- (len _test-registry) 1)))
|
(let ((i (- (len _test-registry) 1)))
|
||||||
(when (>= i 0) (dict-set! (nth _test-registry i) "name" name)))))`);
|
(when (>= i 0) (dict-set! (nth _test-registry i) "name" name)))))`);
|
||||||
|
// eval-hs: compile and evaluate a hyperscript expression/command, return its value.
|
||||||
|
// If src contains 'return', use as-is. If it starts with a command keyword (set/put/get),
|
||||||
|
// use as-is (the last expression is the result). Otherwise wrap in 'return'.
|
||||||
|
K.eval(`(define eval-hs (fn (src)
|
||||||
|
(let ((has-cmd (or (string-contains? src "return ")
|
||||||
|
(string-contains? src "then ")
|
||||||
|
(= "set " (slice src 0 4))
|
||||||
|
(= "put " (slice src 0 4))
|
||||||
|
(= "get " (slice src 0 4)))))
|
||||||
|
(let ((wrapped (if has-cmd src (str "return " src))))
|
||||||
|
(let ((sx (hs-to-sx-from-source wrapped)))
|
||||||
|
(eval-expr sx))))))`);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const f of TEST_FILES) {
|
for (const f of TEST_FILES) {
|
||||||
@@ -256,19 +268,25 @@ test.describe('Hyperscript behavioral tests', () => {
|
|||||||
for (const [t, n] of Object.entries(errTypes).sort((a,b) => b[1] - a[1])) {
|
for (const [t, n] of Object.entries(errTypes).sort((a,b) => b[1] - a[1])) {
|
||||||
console.log(` ${t}: ${n}`);
|
console.log(` ${t}: ${n}`);
|
||||||
}
|
}
|
||||||
// Show sample failures per type
|
// Show ALL crash errors (deduplicated by error message)
|
||||||
for (const [t, n] of Object.entries(errTypes).sort((a,b) => b[1] - a[1])) {
|
const uniqueErrors = {};
|
||||||
const samples = results.filter(r => !r.p).filter(r => {
|
for (const r of results.filter(r => !r.p)) {
|
||||||
const e = r.e || '';
|
const e = (r.e || '').slice(0, 100);
|
||||||
if (t === 'crash') return e.includes('callFn');
|
if (!uniqueErrors[e]) uniqueErrors[e] = { count: 0, example: r };
|
||||||
if (t === 'stub') return e.includes('NOT IMPLEMENTED');
|
uniqueErrors[e].count++;
|
||||||
if (t === 'timeout') return e === 'TIMEOUT';
|
}
|
||||||
return false;
|
console.log(` Unique error messages (${Object.keys(uniqueErrors).length}):`);
|
||||||
}).slice(0, 3);
|
for (const [e, info] of Object.entries(uniqueErrors).sort((a,b) => b[1].count - a[1].count).slice(0, 25)) {
|
||||||
for (const s of samples) console.log(` ${s.s}/${s.n}: ${(s.e||'').slice(0, 120)}`);
|
console.log(` [${info.count}x] ${e}`);
|
||||||
|
}
|
||||||
|
// Show samples of "bar" error specifically
|
||||||
|
const barSamples = results.filter(r => !r.p && (r.e||'').endsWith('bar') || (r.e||'').endsWith('bar ')).slice(0, 8);
|
||||||
|
if (barSamples.length > 0) {
|
||||||
|
console.log(` "bar" error samples (${barSamples.length}):`);
|
||||||
|
for (const s of barSamples) console.log(` ${s.s}/${s.n}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(results.length).toBeGreaterThanOrEqual(830);
|
expect(results.length).toBeGreaterThanOrEqual(940);
|
||||||
expect(passed).toBeGreaterThanOrEqual(420);
|
expect(passed).toBeGreaterThanOrEqual(420);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user