From 03f0929fdf3af30d285ae4458eeb57b062201402 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 3 Mar 2026 20:37:17 +0000 Subject: [PATCH] Fix SX nav morphing, retry error modal, and aria-selected CSS extraction - Re-read verb URL from element attributes at execution time so morphed nav links navigate to the correct destination - Reset retry backoff on fresh requests; skip error modal when sx-retry handles the failure - Strip attribute selectors in CSS registry so aria-selected:* classes resolve correctly for on-demand CSS - Add @css annotations for dynamic aria-selected variant classes - Add SX docs integration test suite (102 tests) Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/body.js | 4 +- shared/static/scripts/sx.js | 8 + shared/sx/css_registry.py | 2 + shared/sx/templates/layout.sx | 1 + shared/tests/test_sx_app_pages.py | 443 ++++++++++++++++++++++++++++++ 5 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 shared/tests/test_sx_app_pages.py diff --git a/shared/static/scripts/body.js b/shared/static/scripts/body.js index 3db0826..b70debb 100644 --- a/shared/static/scripts/body.js +++ b/shared/static/scripts/body.js @@ -542,8 +542,10 @@ document.addEventListener('DOMContentLoaded', () => { document.body.addEventListener("sx:responseError", function (event) { var resp = event.detail.response; if (!resp) return; - var status = resp.status || 0; + // Don't show error modal when sx-retry will handle the failure var triggerEl = event.target; + if (triggerEl && triggerEl.getAttribute("sx-retry")) return; + var status = resp.status || 0; var form = triggerEl ? triggerEl.closest("form") : null; var title = "Something went wrong"; diff --git a/shared/static/scripts/sx.js b/shared/static/scripts/sx.js index 136a3d0..c6769e7 100644 --- a/shared/static/scripts/sx.js +++ b/shared/static/scripts/sx.js @@ -1685,9 +1685,17 @@ // ---- Request executor ------------------------------------------------- function executeRequest(el, verbInfo, extraParams) { + // Re-read verb from element in case attributes were morphed since binding + var currentVerb = getVerb(el); + if (currentVerb) verbInfo = currentVerb; var method = verbInfo.method; var url = verbInfo.url; + // Reset retry backoff on fresh (non-retry) requests + if (!el.classList.contains("sx-error")) { + el.removeAttribute("data-sx-retry-ms"); + } + // sx-media: skip if media query doesn't match var media = el.getAttribute("sx-media"); if (media && !window.matchMedia(media).matches) return Promise.resolve(); diff --git a/shared/sx/css_registry.py b/shared/sx/css_registry.py index 114f95e..c6a2a2c 100644 --- a/shared/sx/css_registry.py +++ b/shared/sx/css_registry.py @@ -252,6 +252,8 @@ def _css_selector_to_class(selector: str) -> str: i += 2 elif name[i] == ':': break # pseudo-class — stop here + elif name[i] == '[': + break # attribute selector — stop here else: result.append(name[i]) i += 1 diff --git a/shared/sx/templates/layout.sx b/shared/sx/templates/layout.sx index 507fd53..ba7571e 100644 --- a/shared/sx/templates/layout.sx +++ b/shared/sx/templates/layout.sx @@ -83,6 +83,7 @@ (when auth-menu auth-menu)))) ; @css bg-sky-400 bg-sky-300 bg-sky-200 bg-sky-100 bg-violet-400 bg-violet-300 bg-violet-200 bg-violet-100 +; @css aria-selected:bg-violet-200 aria-selected:text-violet-900 aria-selected:bg-stone-500 aria-selected:text-white (defcomp ~menu-row-sx (&key id level colour link-href link-label link-label-content icon selected hx-select nav child-id child oob external) (let* ((c (or colour "sky")) diff --git a/shared/tests/test_sx_app_pages.py b/shared/tests/test_sx_app_pages.py new file mode 100644 index 0000000..4830dcf --- /dev/null +++ b/shared/tests/test_sx_app_pages.py @@ -0,0 +1,443 @@ +"""Integration tests for SX docs app — page rendering + interactive API endpoints. + +Runs inside the test container, hitting the sx_docs service over the internal +network. Uses ``SX-Request: true`` header to bypass the silent-SSO OAuth +redirect on page requests. + +Tested: + - All 27 example pages render with 200 and contain meaningful content + - All 23 attribute detail pages render and mention the attribute name + - All 35+ interactive API endpoints return 200 with expected content +""" +from __future__ import annotations + +import os +import re + +import httpx +import pytest + +SX_BASE = os.environ.get("INTERNAL_URL_SX", "http://sx_docs:8000") +HEADERS = {"SX-Request": "true"} +TIMEOUT = 15.0 + + +def _get(path: str, **kw) -> httpx.Response: + return httpx.get( + f"{SX_BASE}{path}", + headers=HEADERS, + timeout=TIMEOUT, + follow_redirects=True, + **kw, + ) + + +def _post(path: str, **kw) -> httpx.Response: + return httpx.post( + f"{SX_BASE}{path}", + headers=HEADERS, + timeout=TIMEOUT, + follow_redirects=True, + **kw, + ) + + +def _put(path: str, **kw) -> httpx.Response: + return httpx.put( + f"{SX_BASE}{path}", + headers=HEADERS, + timeout=TIMEOUT, + follow_redirects=True, + **kw, + ) + + +def _patch(path: str, **kw) -> httpx.Response: + return httpx.patch( + f"{SX_BASE}{path}", + headers=HEADERS, + timeout=TIMEOUT, + follow_redirects=True, + **kw, + ) + + +def _delete(path: str, **kw) -> httpx.Response: + return httpx.delete( + f"{SX_BASE}{path}", + headers=HEADERS, + timeout=TIMEOUT, + follow_redirects=True, + **kw, + ) + + +# ── Check that the sx_docs service is reachable ────────────────────────── + +def _sx_reachable() -> bool: + try: + r = httpx.get(f"{SX_BASE}/", timeout=5, follow_redirects=False) + return r.status_code in (200, 302) + except Exception: + return False + + +pytestmark = pytest.mark.skipif( + not _sx_reachable(), + reason=f"sx_docs service not reachable at {SX_BASE}", +) + + +# ═════════════════════════════════════════════════════════════════════════ +# Example pages — rendering +# ═════════════════════════════════════════════════════════════════════════ + +EXAMPLES = [ + "click-to-load", + "form-submission", + "polling", + "delete-row", + "inline-edit", + "oob-swaps", + "lazy-loading", + "infinite-scroll", + "progress-bar", + "active-search", + "inline-validation", + "value-select", + "reset-on-submit", + "edit-row", + "bulk-update", + "swap-positions", + "select-filter", + "tabs", + "animations", + "dialogs", + "keyboard-shortcuts", + "put-patch", + "json-encoding", + "vals-and-headers", + "loading-states", + "sync-replace", + "retry", +] + + +@pytest.mark.parametrize("slug", EXAMPLES) +def test_example_page_renders(slug: str): + """Each example page must render successfully on two consecutive loads.""" + for attempt in (1, 2): + r = _get(f"/examples/{slug}") + assert r.status_code == 200, ( + f"/examples/{slug} returned {r.status_code} on attempt {attempt}" + ) + assert len(r.text) > 500, ( + f"/examples/{slug} response too short ({len(r.text)} bytes) on attempt {attempt}" + ) + # Every example page should have a demo section + assert "demo" in r.text.lower() or "example" in r.text.lower(), ( + f"/examples/{slug} missing demo/example content" + ) + + +# ═════════════════════════════════════════════════════════════════════════ +# Attribute detail pages — rendering +# ═════════════════════════════════════════════════════════════════════════ + +ATTRIBUTES = [ + "sx-get", + "sx-post", + "sx-put", + "sx-delete", + "sx-patch", + "sx-trigger", + "sx-target", + "sx-swap", + "sx-swap-oob", + "sx-select", + "sx-confirm", + "sx-push-url", + "sx-sync", + "sx-encoding", + "sx-headers", + "sx-include", + "sx-vals", + "sx-media", + "sx-disable", + "sx-on", # URL slug for sx-on:* + "sx-retry", + "data-sx", + "data-sx-env", +] + + +@pytest.mark.parametrize("slug", ATTRIBUTES) +def test_attribute_page_renders(slug: str): + """Each attribute page must render successfully on two consecutive loads.""" + for attempt in (1, 2): + r = _get(f"/reference/attributes/{slug}") + assert r.status_code == 200, ( + f"/reference/attributes/{slug} returned {r.status_code} on attempt {attempt}" + ) + assert len(r.text) > 500, ( + f"/reference/attributes/{slug} response too short on attempt {attempt}" + ) + # The attribute name (or a prefix of it) should appear somewhere + check = slug.rstrip("*").rstrip(":") + assert check.lower() in r.text.lower(), ( + f"/reference/attributes/{slug} does not mention '{check}'" + ) + + +# ═════════════════════════════════════════════════════════════════════════ +# Example API endpoints — interactive demos +# ═════════════════════════════════════════════════════════════════════════ + +class TestExampleAPIs: + """Test the interactive demo API endpoints.""" + + def test_click_to_load(self): + r = _get("/examples/api/click") + assert r.status_code == 200 + + def test_form_submission(self): + r = _post("/examples/api/form", data={"name": "Alice"}) + assert r.status_code == 200 + assert "Alice" in r.text + + def test_polling(self): + r = _get("/examples/api/poll") + assert r.status_code == 200 + + def test_delete_row(self): + r = _delete("/examples/api/delete/1") + assert r.status_code == 200 + + def test_inline_edit_get(self): + r = _get("/examples/api/edit") + assert r.status_code == 200 + + def test_inline_edit_post(self): + r = _post("/examples/api/edit", data={"name": "New Name"}) + assert r.status_code == 200 + + def test_inline_edit_cancel(self): + r = _get("/examples/api/edit/cancel") + assert r.status_code == 200 + + def test_oob_swap(self): + r = _get("/examples/api/oob") + assert r.status_code == 200 + + def test_lazy_loading(self): + r = _get("/examples/api/lazy") + assert r.status_code == 200 + + def test_infinite_scroll(self): + r = _get("/examples/api/scroll", params={"page": "1"}) + assert r.status_code == 200 + + def test_progress_start(self): + r = _post("/examples/api/progress/start") + assert r.status_code == 200 + + def test_progress_status(self): + r = _get("/examples/api/progress/status") + assert r.status_code == 200 + + def test_active_search(self): + r = _get("/examples/api/search", params={"q": "py"}) + assert r.status_code == 200 + assert "Python" in r.text + + def test_inline_validation(self): + r = _get("/examples/api/validate", params={"email": "test@example.com"}) + assert r.status_code == 200 + + def test_validation_submit(self): + r = _post("/examples/api/validate/submit", data={"email": "test@example.com"}) + assert r.status_code == 200 + + def test_value_select(self): + r = _get("/examples/api/values", params={"category": "Languages"}) + assert r.status_code == 200 + + def test_reset_on_submit(self): + r = _post("/examples/api/reset-submit", data={"message": "hello"}) + assert r.status_code == 200 + + def test_edit_row_get(self): + r = _get("/examples/api/editrow/1") + assert r.status_code == 200 + + def test_edit_row_post(self): + r = _post("/examples/api/editrow/1", data={"name": "X", "price": "10", "stock": "5"}) + assert r.status_code == 200 + + def test_edit_row_cancel(self): + r = _get("/examples/api/editrow/1/cancel") + assert r.status_code == 200 + + def test_bulk_update(self): + r = _post("/examples/api/bulk", data={"ids": ["1", "2"], "status": "active"}) + assert r.status_code == 200 + + def test_swap_positions(self): + r = _post("/examples/api/swap-log") + assert r.status_code == 200 + + def test_dashboard_filter(self): + r = _get("/examples/api/dashboard", params={"region": "all"}) + assert r.status_code == 200 + + def test_tabs(self): + r = _get("/examples/api/tabs/overview") + assert r.status_code == 200 + + def test_animate(self): + r = _get("/examples/api/animate") + assert r.status_code == 200 + + def test_dialog_open(self): + r = _get("/examples/api/dialog") + assert r.status_code == 200 + + def test_dialog_close(self): + r = _get("/examples/api/dialog/close") + assert r.status_code == 200 + + def test_keyboard(self): + r = _get("/examples/api/keyboard") + assert r.status_code == 200 + + def test_put_patch_edit(self): + r = _get("/examples/api/putpatch/edit-all") + assert r.status_code == 200 + + def test_put_request(self): + r = _put("/examples/api/putpatch", data={"name": "X", "email": "x@x.com", "role": "Dev"}) + assert r.status_code == 200 + + def test_put_patch_cancel(self): + r = _get("/examples/api/putpatch/cancel") + assert r.status_code == 200 + + def test_json_encoding(self): + r = httpx.post( + f"{SX_BASE}/examples/api/json-echo", + content='{"key":"val"}', + headers={**HEADERS, "Content-Type": "application/json"}, + timeout=TIMEOUT, + ) + assert r.status_code == 200 + + def test_echo_vals(self): + r = _get("/examples/api/echo-vals", params={"source": "test"}) + assert r.status_code == 200 + + def test_echo_headers(self): + r = httpx.get( + f"{SX_BASE}/examples/api/echo-headers", + headers={**HEADERS, "X-Custom": "hello"}, + timeout=TIMEOUT, + ) + assert r.status_code == 200 + + def test_slow_endpoint(self): + r = _get("/examples/api/slow") + assert r.status_code == 200 + + def test_slow_search(self): + r = _get("/examples/api/slow-search", params={"q": "test"}) + assert r.status_code == 200 + + def test_flaky_endpoint(self): + # May fail 2/3 times — just check it returns *something* + r = _get("/examples/api/flaky") + assert r.status_code in (200, 503) + + +# ═════════════════════════════════════════════════════════════════════════ +# Reference API endpoints — attribute demos +# ═════════════════════════════════════════════════════════════════════════ + +class TestReferenceAPIs: + """Test the reference attribute demo API endpoints.""" + + def test_time(self): + r = _get("/reference/api/time") + assert r.status_code == 200 + # Should contain a time string (HH:MM:SS pattern) + assert re.search(r"\d{2}:\d{2}:\d{2}", r.text), "No time found in response" + + def test_greet(self): + r = _post("/reference/api/greet", data={"name": "Bob"}) + assert r.status_code == 200 + assert "Bob" in r.text + + def test_status_put(self): + r = _put("/reference/api/status", data={"status": "published"}) + assert r.status_code == 200 + assert "published" in r.text.lower() + + def test_theme_patch(self): + r = _patch("/reference/api/theme", data={"theme": "dark"}) + assert r.status_code == 200 + assert "dark" in r.text.lower() + + def test_delete_item(self): + r = _delete("/reference/api/item/42") + assert r.status_code == 200 + + def test_trigger_search(self): + r = _get("/reference/api/trigger-search", params={"q": "hello"}) + assert r.status_code == 200 + assert "hello" in r.text.lower() + + def test_swap_item(self): + r = _get("/reference/api/swap-item") + assert r.status_code == 200 + + def test_oob(self): + r = _get("/reference/api/oob") + assert r.status_code == 200 + # OOB response should contain sx-swap-oob attribute + assert "oob" in r.text.lower() + + def test_select_page(self): + r = _get("/reference/api/select-page") + assert r.status_code == 200 + assert "the-content" in r.text + + def test_slow_echo(self): + r = _get("/reference/api/slow-echo", params={"q": "sync"}) + assert r.status_code == 200 + assert "sync" in r.text.lower() + + def test_upload_name(self): + r = _post( + "/reference/api/upload-name", + files={"file": ("test.txt", b"hello", "text/plain")}, + ) + assert r.status_code == 200 + assert "test.txt" in r.text + + def test_echo_headers(self): + r = httpx.get( + f"{SX_BASE}/reference/api/echo-headers", + headers={**HEADERS, "X-Custom-Token": "abc123"}, + timeout=TIMEOUT, + ) + assert r.status_code == 200 + + def test_echo_vals_get(self): + r = _get("/reference/api/echo-vals", params={"category": "books"}) + assert r.status_code == 200 + + def test_echo_vals_post(self): + r = _post("/reference/api/echo-vals", data={"source": "demo", "page": "3"}) + assert r.status_code == 200 + + def test_flaky(self): + r = _get("/reference/api/flaky") + assert r.status_code in (200, 503)