Files
rose-ash/sx/tests/test_demos.py
giles 109ca7c70b Fix aser server-affinity expansion: keyword values, OOB wrapper, page helpers
Three bugs in aser-expand-component (adapter-sx.sx):
- Keyword values were eval'd (eval-expr can't handle <>, HTML tags);
  now asered, matching the aser's rendering capabilities
- Missing default nil binding for unset &key params (caused
  "Undefined symbol" errors for optional params like header-rows)
- aserCall string-quoted keyword values that were already serialized
  SX — now inlines values starting with "(" directly

Server-affinity annotations for layout/nav shells:
- ~shared:layout/app-body, ~shared:layout/oob-sx — page structure
- ~layouts/nav-sibling-row, ~layouts/nav-children — server-side data
- ~layouts/doc already had :affinity :server
- ~cssx/flush marked :affinity :client (browser-only state)

Navigation fix: restore oob_page_sx wrapper for HTMX responses
so #main-panel section exists for sx-select/sx-swap targeting.

OCaml bridge: lazy page helper injection into kernel via IO proxy
(define name (fn (...) (helper "name" ...))) — enables aser_slot
to evaluate highlight/component-source etc. via coroutine bridge.

Playwright tests: added pageerror listener to test_no_console_errors,
new test_navigate_from_home_to_geography for HTMX nav regression.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:06:24 +00:00

585 lines
23 KiB
Python

"""Automated Playwright tests for SX docs demos.
Covers hypermedia examples, reactive islands, and marshes.
Run against the dev server:
cd sx/tests
pytest test_demos.py -v --headed # visible browser
pytest test_demos.py -v # headless
Requires: pip install pytest-playwright
"""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
import os
BASE = os.environ.get("SX_TEST_BASE", "https://sx.rose-ash.com")
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def browser_context_args():
return {"ignore_https_errors": True}
def nav(page: Page, path: str):
"""Navigate to an SX URL and wait for rendered content."""
page.goto(f"{BASE}/sx/{path}", wait_until="networkidle")
# Wait for SX to render — look for any heading or paragraph in main panel
page.wait_for_selector("#main-panel h2, #main-panel p, #main-panel div", timeout=30000)
# ---------------------------------------------------------------------------
# Hypermedia Examples
# ---------------------------------------------------------------------------
class TestClickToLoad:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.click-to-load)))")
expect(page.locator("#main-panel")).to_contain_text("Click to Load")
def test_click_loads_content(self, page: Page):
nav(page, "(geography.(hypermedia.(example.click-to-load)))")
page.click("button:has-text('Load content')")
expect(page.locator("#click-result")).to_contain_text(
"Content loaded", timeout=5000
)
class TestFormSubmission:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.form-submission)))")
expect(page.locator("#main-panel")).to_contain_text("Form Submission")
def test_submit_form(self, page: Page):
nav(page, "(geography.(hypermedia.(example.form-submission)))")
page.fill("input[name='name']", "TestUser")
page.click("button[type='submit']")
expect(page.locator("#form-result")).to_contain_text("TestUser", timeout=5000)
class TestPolling:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.polling)))")
expect(page.locator("#main-panel")).to_contain_text("Polling")
def test_poll_updates(self, page: Page):
nav(page, "(geography.(hypermedia.(example.polling)))")
expect(page.locator("#poll-target")).to_contain_text("Server time", timeout=10000)
class TestDeleteRow:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.delete-row)))")
expect(page.locator("#main-panel")).to_contain_text("Delete Row")
class TestInlineEdit:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.inline-edit)))")
expect(page.locator("#main-panel")).to_contain_text("Inline Edit")
def test_edit_shows_form(self, page: Page):
nav(page, "(geography.(hypermedia.(example.inline-edit)))")
page.click("#edit-target button:has-text('edit')")
expect(page.locator("input[name='value']")).to_be_visible(timeout=5000)
class TestOobSwaps:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.oob-swaps)))")
expect(page.locator("#main-panel")).to_contain_text("OOB")
def test_oob_updates_both_boxes(self, page: Page):
nav(page, "(geography.(hypermedia.(example.oob-swaps)))")
page.click("button:has-text('Update both boxes')")
expect(page.locator("#oob-box-a")).to_contain_text("Box A updated", timeout=5000)
expect(page.locator("#oob-box-b").last).to_contain_text("Box B updated", timeout=5000)
class TestLazyLoading:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.lazy-loading)))")
expect(page.locator("#main-panel")).to_contain_text("Lazy Loading")
def test_content_loads_automatically(self, page: Page):
nav(page, "(geography.(hypermedia.(example.lazy-loading)))")
expect(page.locator("#lazy-target")).to_contain_text("Content loaded", timeout=10000)
class TestInfiniteScroll:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.infinite-scroll)))")
expect(page.locator("#main-panel")).to_contain_text("Infinite Scroll")
class TestProgressBar:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.progress-bar)))")
expect(page.locator("#main-panel")).to_contain_text("Progress Bar")
def test_progress_starts(self, page: Page):
nav(page, "(geography.(hypermedia.(example.progress-bar)))")
page.click("button:has-text('Start job')")
expect(page.locator("#progress-target")).to_contain_text("complete", timeout=10000)
class TestActiveSearch:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.active-search)))")
expect(page.locator("#main-panel")).to_contain_text("Active Search")
def test_search_returns_results(self, page: Page):
nav(page, "(geography.(hypermedia.(example.active-search)))")
page.locator("input[name='q']").type("Py")
expect(page.locator("#search-results")).to_contain_text("Python", timeout=5000)
class TestInlineValidation:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.inline-validation)))")
expect(page.locator("#main-panel")).to_contain_text("Inline Validation")
def test_invalid_email(self, page: Page):
nav(page, "(geography.(hypermedia.(example.inline-validation)))")
page.fill("input[name='email']", "notanemail")
page.locator("input[name='email']").blur()
expect(page.locator("#email-feedback")).to_contain_text("Invalid", timeout=5000)
class TestValueSelect:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.value-select)))")
expect(page.locator("#main-panel")).to_contain_text("Value Select")
def test_select_changes_options(self, page: Page):
nav(page, "(geography.(hypermedia.(example.value-select)))")
page.select_option("select[name='category']", "Languages")
expect(page.locator("#value-items")).to_contain_text("Python", timeout=5000)
class TestResetOnSubmit:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.reset-on-submit)))")
expect(page.locator("#main-panel")).to_contain_text("Reset")
def test_has_form(self, page: Page):
nav(page, "(geography.(hypermedia.(example.reset-on-submit)))")
expect(page.locator("form")).to_be_visible(timeout=5000)
class TestEditRow:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.edit-row)))")
expect(page.locator("#main-panel")).to_contain_text("Edit Row")
class TestBulkUpdate:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.bulk-update)))")
expect(page.locator("#main-panel")).to_contain_text("Bulk Update")
def test_has_checkboxes(self, page: Page):
nav(page, "(geography.(hypermedia.(example.bulk-update)))")
expect(page.locator("input[type='checkbox']").first).to_be_visible(timeout=5000)
class TestSwapPositions:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.swap-positions)))")
expect(page.locator("#main-panel")).to_contain_text("Swap")
class TestSelectFilter:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.select-filter)))")
expect(page.locator("#main-panel")).to_contain_text("Select Filter")
class TestTabs:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.tabs)))")
expect(page.locator("#main-panel")).to_contain_text("Tabs")
class TestAnimations:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.animations)))")
expect(page.locator("#main-panel")).to_contain_text("Animation")
class TestDialogs:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.dialogs)))")
expect(page.locator("#main-panel")).to_contain_text("Dialog")
class TestKeyboardShortcuts:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.keyboard-shortcuts)))")
expect(page.locator("#main-panel")).to_contain_text("Keyboard")
class TestPutPatch:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.put-patch)))")
expect(page.locator("#main-panel")).to_contain_text("PUT")
class TestJsonEncoding:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.json-encoding)))")
expect(page.locator("#main-panel")).to_contain_text("JSON")
class TestValsAndHeaders:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.vals-and-headers)))")
expect(page.locator("#main-panel")).to_contain_text("Vals")
class TestLoadingStates:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.loading-states)))")
expect(page.locator("#main-panel")).to_contain_text("Loading")
class TestSyncReplace:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.sync-replace)))")
expect(page.locator("#main-panel")).to_contain_text("Request Abort")
class TestRetry:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.retry)))")
expect(page.locator("#main-panel")).to_contain_text("Retry")
# ---------------------------------------------------------------------------
# Reactive Islands
# ---------------------------------------------------------------------------
class TestReactiveIslandsOverview:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive))")
expect(page.locator("#main-panel")).to_contain_text("Reactive Islands")
def test_architecture_table(self, page: Page):
nav(page, "(geography.(reactive))")
expect(page.locator("table").first).to_be_visible()
class TestReactiveExamplesOverview:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples)))")
expect(page.locator("#main-panel")).to_contain_text("Examples")
class TestReactiveCounter:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.counter)))")
expect(page.locator("#main-panel")).to_contain_text("signal holds a value")
def test_counter_increments(self, page: Page):
nav(page, "(geography.(reactive.(examples.counter)))")
# Find the island's + button
island = page.locator("[data-sx-island*='demo-counter']")
expect(island).to_be_visible(timeout=5000)
# Wait for hydration
page.wait_for_timeout(1000)
initial = island.locator("span.text-2xl").text_content()
island.locator("button", has_text="+").click()
page.wait_for_timeout(500)
updated = island.locator("span.text-2xl").text_content()
assert initial != updated, f"Counter should change: {initial} -> {updated}"
class TestReactiveTemperature:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.temperature)))")
expect(page.locator("#main-panel")).to_contain_text("Temperature")
def test_temperature_updates(self, page: Page):
nav(page, "(geography.(reactive.(examples.temperature)))")
island = page.locator("[data-sx-island*='demo-temperature']")
expect(island).to_be_visible(timeout=5000)
page.wait_for_timeout(1000)
island.locator("button", has_text="+5").click()
page.wait_for_timeout(500)
# Should show celsius value > 20 (default)
expect(island).to_contain_text("25")
class TestReactiveStopwatch:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.stopwatch)))")
expect(page.locator("#main-panel")).to_contain_text("Stopwatch")
class TestReactiveImperative:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.imperative)))")
expect(page.locator("#main-panel")).to_contain_text("Imperative")
class TestReactiveList:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.reactive-list)))")
expect(page.locator("#main-panel")).to_contain_text("Reactive List")
def test_add_item(self, page: Page):
nav(page, "(geography.(reactive.(examples.reactive-list)))")
island = page.locator("[data-sx-island*='demo-reactive-list']")
expect(island).to_be_visible(timeout=5000)
page.wait_for_timeout(1000)
island.locator("button", has_text="Add Item").click()
page.wait_for_timeout(500)
expect(island).to_contain_text("Item 1")
class TestReactiveInputBinding:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.input-binding)))")
expect(page.locator("#main-panel")).to_contain_text("Input Binding")
def test_input_binding(self, page: Page):
nav(page, "(geography.(reactive.(examples.input-binding)))")
island = page.locator("[data-sx-island*='demo-input-binding']")
expect(island).to_be_visible(timeout=5000)
page.wait_for_timeout(1000)
island.locator("input[type='text']").fill("World")
page.wait_for_timeout(500)
expect(island).to_contain_text("Hello, World!")
class TestReactivePortal:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.portal)))")
expect(page.locator("#main-panel")).to_contain_text("Portal")
class TestReactiveErrorBoundary:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.error-boundary)))")
expect(page.locator("#main-panel")).to_contain_text("Error")
class TestReactiveRefs:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.refs)))")
expect(page.locator("#main-panel")).to_contain_text("Refs")
class TestReactiveDynamicClass:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.dynamic-class)))")
expect(page.locator("#main-panel")).to_contain_text("Dynamic")
class TestReactiveResource:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.resource)))")
expect(page.locator("#main-panel")).to_contain_text("Resource")
class TestReactiveTransition:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.transition)))")
expect(page.locator("#main-panel")).to_contain_text("Transition")
class TestReactiveStores:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.stores)))")
expect(page.locator("#main-panel")).to_contain_text("Store")
class TestReactiveEventBridge:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.event-bridge-demo)))")
expect(page.locator("#main-panel")).to_contain_text("Event Bridge")
class TestReactiveDefisland:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.defisland)))")
expect(page.locator("#main-panel")).to_contain_text("defisland")
class TestReactiveTests:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.tests)))")
expect(page.locator("#main-panel")).to_contain_text("test")
class TestReactiveCoverage:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.coverage)))")
expect(page.locator("#main-panel")).to_contain_text("React")
# ---------------------------------------------------------------------------
# Marshes
# ---------------------------------------------------------------------------
class TestMarshesOverview:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes))")
expect(page.locator("#main-panel")).to_contain_text("Marshes")
class TestMarshesHypermediaFeeds:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes.hypermedia-feeds))")
expect(page.locator("#main-panel")).to_contain_text("Hypermedia Feeds")
class TestMarshesServerSignals:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes.server-signals))")
expect(page.locator("#main-panel")).to_contain_text("Server Writes")
class TestMarshesOnSettle:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes.on-settle))")
expect(page.locator("#main-panel")).to_contain_text("sx-on-settle")
class TestMarshesSignalTriggers:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes.signal-triggers))")
expect(page.locator("#main-panel")).to_contain_text("Signal-Bound")
class TestMarshesViewTransform:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes.view-transform))")
expect(page.locator("#main-panel")).to_contain_text("Reactive View")
# ---------------------------------------------------------------------------
# Spec Explorer
# ---------------------------------------------------------------------------
class TestSpecExplorer:
def test_evaluator_server_renders(self, page: Page):
"""Server returns spec explorer content with evaluator data."""
resp = page.request.get(f"{BASE}/sx/(language.(spec.(explore.evaluator)))")
assert resp.ok, f"Server returned {resp.status}"
body = resp.text()
assert "Evaluator" in body, "Should contain evaluator title"
assert "eval" in body.lower(), "Should contain evaluator content"
def test_parser_server_renders(self, page: Page):
"""Server returns spec explorer content with parser data."""
resp = page.request.get(f"{BASE}/sx/(language.(spec.(explore.parser)))")
assert resp.ok, f"Server returned {resp.status}"
body = resp.text()
assert "Parser" in body, "Should contain parser title"
def test_has_spec_source(self, page: Page):
"""Spec explorer includes actual spec source code."""
resp = page.request.get(f"{BASE}/sx/(language.(spec.(explore.evaluator)))")
body = resp.text()
assert "define" in body, "Should contain define forms from spec"
assert "eval-expr" in body, "Should contain eval-expr from evaluator spec"
@pytest.mark.xfail(reason="Client re-evaluates defpage content; find-spec unavailable on client")
def test_evaluator_renders_in_browser(self, page: Page):
"""Spec explorer should render correctly in the browser, not show 'not found'."""
nav(page, "(language.(spec.(explore.evaluator)))")
page.wait_for_timeout(3000)
content = page.locator("#main-panel").text_content() or ""
assert "not found" not in content.lower(), \
f"Page shows 'not found' instead of spec content: {content[:200]}"
expect(page.locator("#main-panel")).to_contain_text("Evaluator", timeout=5000)
# ---------------------------------------------------------------------------
# Key doc pages (smoke tests)
# ---------------------------------------------------------------------------
class TestHomePage:
def test_home_loads(self, page: Page):
nav(page, "")
expect(page.locator("#main-panel")).to_contain_text("sx", timeout=10000)
def test_no_console_errors(self, page: Page):
"""Home page should have no JS errors (console or uncaught)."""
errors = []
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
page.on("pageerror", lambda err: errors.append(f"UNCAUGHT: {err.message}"))
page.goto(f"{BASE}/sx/", wait_until="networkidle")
page.wait_for_timeout(3000)
fatal = [e for e in errors if "Not callable" in e or "Undefined symbol" in e or "SES_UNCAUGHT" in e or "UNCAUGHT" in e]
assert not fatal, f"JS errors on home page: {fatal}"
def test_navigate_from_home_to_geography(self, page: Page):
"""Click Geography nav link from home — content must render."""
errors = []
page.on("pageerror", lambda err: errors.append(f"UNCAUGHT: {err.message}"))
nav(page, "")
# Click the Geography link in the nav children
geo_link = page.locator("a[sx-push-url]:has-text('Geography')").first
expect(geo_link).to_be_visible(timeout=10000)
geo_link.click()
page.wait_for_timeout(5000)
# Content must still be visible after navigation
expect(page.locator("#main-panel")).to_contain_text("Geography", timeout=10000)
fatal = [e for e in errors if "Not callable" in e or "Undefined symbol" in e or "UNCAUGHT" in e]
assert not fatal, f"JS errors after navigation: {fatal}"
class TestDocPages:
@pytest.mark.parametrize("path,expected", [
("(geography.(reactive))", "Reactive Islands"),
("(geography.(hypermedia.(reference.attributes)))", "Attributes"),
("(geography.(scopes))", "Scopes"),
("(geography.(provide))", "Provide"),
("(geography.(spreads))", "Spreads"),
("(language.(doc.introduction))", "Introduction"),
("(language.(spec.core))", "Core"),
("(applications.(cssx))", "CSSX"),
("(etc.(essay.why-sexps))", "Why S-Expressions"),
])
def test_page_loads(self, page: Page, path: str, expected: str):
nav(page, path)
expect(page.locator("#main-panel")).to_contain_text(expected, timeout=10000)
# ---------------------------------------------------------------------------
# Navigation tests
# ---------------------------------------------------------------------------
class TestClientNavigation:
def test_navigate_between_reactive_examples(self, page: Page):
"""Navigate from counter to temperature via server fetch."""
nav(page, "(geography.(reactive.(examples.counter)))")
expect(page.locator("#main-panel")).to_contain_text("signal holds a value", timeout=10000)
# Click temperature link in sibling nav — has sx-get for server fetch
temp_link = page.locator("a[sx-push-url]:has-text('Temperature')").first
expect(temp_link).to_be_visible(timeout=5000)
temp_link.click()
page.wait_for_timeout(5000)
expect(page.locator("#main-panel")).to_contain_text("Temperature", timeout=10000)
def test_reactive_island_works_after_navigation(self, page: Page):
"""Island should be functional after client-side navigation."""
nav(page, "(geography.(reactive.(examples.temperature)))")
page.wait_for_timeout(3000) # Wait for hydration
# Click +5 and verify
island = page.locator("[data-sx-island*='demo-temperature']")
expect(island).to_be_visible(timeout=5000)
island.locator("button", has_text="+5").click()
page.wait_for_timeout(500)
expect(island).to_contain_text("25")