Aser serialization: aser-call/fragment now return SxExpr instead of String. serialize/inspect passes SxExpr through unquoted, preventing the double- escaping (\" → \\\" ) that broke client-side parsing when aser wire format was output via raw! into <script> tags. Added make-sx-expr + sx-expr-source primitives to OCaml and JS hosts. Binary blob protocol: eval, aser, aser-slot, and sx-page-full now send SX source as length-prefixed blobs instead of escaped strings. Eliminates pipe desync from concurrent requests and removes all string-escape round-trips between Python and OCaml. Bridge safety: re-entrancy guard (_in_io_handler) raises immediately if an IO handler tries to call the bridge, preventing silent deadlocks. Fetch error logging: orchestration.sx error callback now logs method + URL via log-warn. Platform catches (fetchAndRestore, fetchPreload, bindBoostForm) also log errors instead of silently swallowing them. Transpiler fixes: makeEnv, scopePeek, scopeEmit, makeSxExpr added as platform function definitions + transpiler mappings — were referenced in transpiled code but never defined as JS functions. Playwright test infrastructure: - nav() captures JS errors and fails fast with the actual error message - Checks for [object Object] rendering artifacts - New tests: delete-row interaction, full page refresh, back button, direct load with fresh context, code block content verification - Default base URL changed to localhost:8013 (standalone dev server) - docker-compose.dev-sx.yml: port 8013 exposed for local testing - test-sx-build.sh: build + unit tests + Playwright smoke tests Geography content: index page component written (sx/sx/geography/index.sx) describing OCaml evaluator, wire formats, rendering pipeline, and topic links. Wiring blocked by aser-expand-component children passing issue. Tests: 1080/1080 JS, 952/952 OCaml, 66/66 Playwright Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
735 lines
30 KiB
Python
735 lines
30 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", "http://localhost:8013")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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.
|
|
|
|
Captures JS errors during page load and fails immediately with the
|
|
actual error message instead of waiting for a 30s timeout.
|
|
"""
|
|
js_errors: list[str] = []
|
|
page.on("pageerror", lambda err: js_errors.append(str(err)))
|
|
|
|
page.goto(f"{BASE}/sx/{path}", wait_until="networkidle")
|
|
|
|
# Poll briefly for JS errors — pageerror fires async during networkidle
|
|
for _ in range(10):
|
|
if js_errors:
|
|
break
|
|
page.wait_for_timeout(100)
|
|
|
|
# Fail fast on JS errors — don't wait for content that will never appear
|
|
if js_errors:
|
|
pytest.fail(f"JS error on {path}: {js_errors[0]}")
|
|
|
|
# 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=10000)
|
|
|
|
# Check for rendering artifacts that indicate broken serialization
|
|
main_text = page.locator("#main-panel").text_content() or ""
|
|
if "[object Object]" in main_text:
|
|
pytest.fail(f"Rendering artifact on {path}: [object Object] in page content")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_has_code_examples(self, page: Page):
|
|
"""Page must show component source and handler source code."""
|
|
nav(page, "(geography.(hypermedia.(example.form-submission)))")
|
|
code_blocks = page.locator("pre code")
|
|
expect(code_blocks.first).not_to_be_empty(timeout=5000)
|
|
# Should have at least component code and handler code
|
|
count = code_blocks.count()
|
|
assert count >= 2, f"Expected at least 2 code blocks, got {count}"
|
|
# Handler code should contain defhandler
|
|
all_code = " ".join(code_blocks.nth(i).text_content() or ""
|
|
for i in range(count))
|
|
assert "defhandler" in all_code or "sx-post" in all_code, \
|
|
f"Code blocks should contain handler or component source"
|
|
|
|
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")
|
|
|
|
def test_delete_removes_row(self, page: Page):
|
|
nav(page, "(geography.(hypermedia.(example.delete-row)))")
|
|
rows = page.locator("#delete-rows tr")
|
|
initial_count = rows.count()
|
|
assert initial_count >= 2, f"Expected at least 2 rows, got {initial_count}"
|
|
# Accept the confirm dialog
|
|
page.on("dialog", lambda d: d.accept())
|
|
rows.first.locator("button:has-text('delete')").click()
|
|
expect(rows).to_have_count(initial_count - 1, timeout=5000)
|
|
|
|
|
|
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)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _check_no_fatal_errors(errors):
|
|
"""Assert no fatal JS errors were collected."""
|
|
fatal = [e for e in errors
|
|
if any(s in e for s in ("Not callable", "Undefined symbol",
|
|
"SES_UNCAUGHT", "UNCAUGHT"))]
|
|
assert not fatal, f"JS errors: {fatal}"
|
|
|
|
|
|
def _setup_error_capture(page):
|
|
"""Attach error listeners, return the errors list."""
|
|
errors = []
|
|
page.on("pageerror", lambda err: errors.append(f"UNCAUGHT: {err.message}"))
|
|
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
|
|
return errors
|
|
|
|
|
|
class TestHomePage:
|
|
def test_home_has_header(self, page: Page):
|
|
"""Home page MUST have the (<sx>) header with logo."""
|
|
errors = _setup_error_capture(page)
|
|
nav(page, "")
|
|
# Header logo — the (<sx>) text
|
|
expect(page.locator("#logo-opacity")).to_be_visible(timeout=15000)
|
|
_check_no_fatal_errors(errors)
|
|
|
|
def test_home_has_nav_children(self, page: Page):
|
|
"""Home page MUST have nav children (Geography, Language, etc.)."""
|
|
nav(page, "")
|
|
expect(page.locator("a[sx-push-url]:has-text('Geography')")).to_be_visible(timeout=15000)
|
|
|
|
def test_home_has_main_panel(self, page: Page):
|
|
"""Home page MUST render #main-panel with content inside it."""
|
|
nav(page, "")
|
|
expect(page.locator("#main-panel")).to_be_visible(timeout=15000)
|
|
# Must have actual content (divs/headings), not be empty
|
|
page.wait_for_selector("#main-panel div, #main-panel h2, #main-panel p", timeout=15000)
|
|
|
|
def test_no_console_errors(self, page: Page):
|
|
"""Home page should have no JS errors (console or uncaught)."""
|
|
errors = _setup_error_capture(page)
|
|
page.goto(f"{BASE}/sx/", wait_until="networkidle")
|
|
page.wait_for_timeout(3000)
|
|
_check_no_fatal_errors(errors)
|
|
|
|
def test_navigate_from_home_to_geography(self, page: Page):
|
|
"""Click Geography nav link from home — content must render."""
|
|
errors = _setup_error_capture(page)
|
|
nav(page, "")
|
|
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)
|
|
expect(page.locator("#main-panel")).to_contain_text("Geography", timeout=10000)
|
|
_check_no_fatal_errors(errors)
|
|
|
|
def test_navigate_geography_to_reactive(self, page: Page):
|
|
"""Click Reactive Islands from Geography — content must render."""
|
|
errors = _setup_error_capture(page)
|
|
nav(page, "(geography)")
|
|
ri_link = page.locator("a[sx-push-url]:has-text('Reactive Islands')").first
|
|
expect(ri_link).to_be_visible(timeout=10000)
|
|
ri_link.click()
|
|
page.wait_for_timeout(5000)
|
|
expect(page.locator("#main-panel")).to_contain_text("Reactive Islands", timeout=10000)
|
|
_check_no_fatal_errors(errors)
|
|
|
|
|
|
class TestDirectPageLoad:
|
|
"""Direct page loads (not HTMX) — each uses a FRESH browser context
|
|
to avoid stale component hash in localStorage from prior tests."""
|
|
|
|
@pytest.mark.parametrize("path,expected_text", [
|
|
("(geography.(reactive))", "Reactive Islands"),
|
|
("(etc)", "Etc"),
|
|
("(geography)", "Geography"),
|
|
])
|
|
def test_page_renders_with_header(self, browser, path: str, expected_text: str):
|
|
"""Page must have header, #main-panel, and expected content."""
|
|
ctx = browser.new_context(ignore_https_errors=True)
|
|
page = ctx.new_page()
|
|
try:
|
|
errors = _setup_error_capture(page)
|
|
page.goto(f"{BASE}/sx/{path}", wait_until="networkidle")
|
|
page.wait_for_selector("#main-panel", timeout=30000)
|
|
expect(page.locator("#main-panel")).to_contain_text(expected_text, timeout=10000)
|
|
expect(page.locator("#sx-nav")).to_be_visible(timeout=5000)
|
|
_check_no_fatal_errors(errors)
|
|
finally:
|
|
ctx.close()
|
|
|
|
|
|
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")
|
|
|
|
def test_full_refresh_preserves_content(self, page: Page):
|
|
"""Navigate to a page, then hard-refresh — content must re-render."""
|
|
errors = _setup_error_capture(page)
|
|
nav(page, "(geography.(hypermedia.(example.click-to-load)))")
|
|
expect(page.locator("#main-panel")).to_contain_text("Click to Load")
|
|
# Hard refresh
|
|
page.reload(wait_until="networkidle")
|
|
page.wait_for_timeout(2000)
|
|
expect(page.locator("#main-panel")).to_contain_text("Click to Load", timeout=10000)
|
|
_check_no_fatal_errors(errors)
|
|
|
|
def test_back_button_after_navigation(self, page: Page):
|
|
"""Navigate A → B, then back — A must re-render without errors."""
|
|
errors = _setup_error_capture(page)
|
|
nav(page, "(geography.(reactive.(examples.counter)))")
|
|
expect(page.locator("#main-panel")).to_contain_text("signal holds a value", timeout=10000)
|
|
|
|
# Navigate to temperature
|
|
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(3000)
|
|
expect(page.locator("#main-panel")).to_contain_text("Temperature", timeout=10000)
|
|
|
|
# Go back
|
|
page.go_back()
|
|
page.wait_for_timeout(3000)
|
|
expect(page.locator("#main-panel")).to_contain_text("signal holds a value", timeout=10000)
|
|
_check_no_fatal_errors(errors)
|
|
|
|
def test_direct_load_no_js_errors(self, browser):
|
|
"""Fresh browser context — direct page load must have zero JS errors."""
|
|
ctx = browser.new_context(ignore_https_errors=True)
|
|
page = ctx.new_page()
|
|
try:
|
|
errors = _setup_error_capture(page)
|
|
page.goto(f"{BASE}/sx/(geography.(hypermedia.(example.delete-row)))", wait_until="networkidle")
|
|
page.wait_for_timeout(3000)
|
|
page.wait_for_selector("#main-panel", timeout=15000)
|
|
_check_no_fatal_errors(errors)
|
|
finally:
|
|
ctx.close()
|