Implement missing SxEngine features: - SSE (sx-sse, sx-sse-swap) with EventSource management and auto-cleanup - Response headers: SX-Trigger, SX-Retarget, SX-Reswap, SX-Redirect, SX-Refresh, SX-Location, SX-Replace-Url, SX-Trigger-After-Swap/Settle - View Transitions API: transition:true swap modifier + global config - every:<time> trigger for polling (setInterval) - sx-replace-url (replaceState instead of pushState) - sx-disabled-elt (disable elements during request) - sx-prompt (window.prompt, value sent as SX-Prompt header) - sx-params (filter form parameters: *, none, not x,y, x,y) Adds docs (ATTR_DETAILS, BEHAVIOR_ATTRS, headers, events), demo components in reference.sx, API endpoints (prompt-echo, sse-time), and 27 new unit tests for engine logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
478 lines
15 KiB
Python
478 lines
15 KiB
Python
"""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-boost",
|
|
"sx-preload",
|
|
"sx-preserve",
|
|
"sx-indicator",
|
|
"sx-validate",
|
|
"sx-ignore",
|
|
"sx-optimistic",
|
|
"sx-replace-url",
|
|
"sx-disabled-elt",
|
|
"sx-prompt",
|
|
"sx-params",
|
|
"sx-sse",
|
|
"sx-sse-swap",
|
|
"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)
|
|
|
|
def test_prompt_echo(self):
|
|
r = httpx.get(
|
|
f"{SX_BASE}/reference/api/prompt-echo",
|
|
headers={**HEADERS, "SX-Prompt": "Alice"},
|
|
timeout=TIMEOUT,
|
|
)
|
|
assert r.status_code == 200
|
|
assert "Alice" in r.text
|
|
|
|
def test_sse_time(self):
|
|
"""SSE endpoint returns event-stream content type."""
|
|
with httpx.stream("GET", f"{SX_BASE}/reference/api/sse-time",
|
|
headers=HEADERS, timeout=TIMEOUT) as r:
|
|
assert r.status_code == 200
|
|
ct = r.headers.get("content-type", "")
|
|
assert "text/event-stream" in ct
|
|
# Read just the first chunk to verify format
|
|
for chunk in r.iter_text():
|
|
assert "event:" in chunk or "data:" in chunk
|
|
break # only need the first chunk
|