Files
photoanalyzer/tests/e2e/test_frontend_shell.py
domverse ce5c8db5bc US02-05: Build the Static Application Shell
Add the reusable browser shell primitives Phase B views build on:

- store.js: observable store (get/set/subscribe)
- events.js: job activity adapter — SSE preferred, polling fallback,
  sharing the event `seq` as cursor so a transport switch drops nothing
- api.js: cancellable() (AbortController) + job endpoints; aborted
  requests reject with code "cancelled"

Make the SSE stream generically consumable: emit default `message`
events with the type in the JSON payload instead of `event: <type>`, so
a browser EventSource receives the open-ended type set (state:*,
claimed, …) via onmessage without enumerating it. The `event: done`
sentinel and resumable `id:` cursors are unchanged.

Tests: frontend/js/tests/ in-browser unit suite (api errors +
cancellation, store transitions, routing, SSE→polling fallback) served
over the app's static mount and driven by tests/e2e/test_frontend_shell.py,
which also asserts asset loading, deep-link + reload restore, JSON-only
/api/v1, and a clean console/network. Reuses the installed playwright —
no JS toolchain added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 15:47:52 +02:00

144 lines
4.9 KiB
Python

"""Static application shell: the JS unit suite plus browser journeys asserting
asset loading, deep links, reload-restores-filters, JSON-only APIs, and a clean
console/network (US02-05).
The unit suite (frontend/js/tests/) runs in the browser over the server's own
static mount — ES modules can't import over file://, so the running server serves
them; the tests themselves stub fetch/EventSource and never touch the API.
"""
import socket
import subprocess
import sys
import time
from pathlib import Path
from types import SimpleNamespace
import httpx
import numpy as np
import pytest
from PIL import Image
REPO = Path(__file__).resolve().parents[2]
def _image(path, seed):
rng = np.random.default_rng(seed)
Image.fromarray(rng.integers(0, 256, (96, 128, 3), dtype=np.uint8)).save(path, quality=90)
def _free_port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
@pytest.fixture
def server(tmp_path):
data = tmp_path / "data"
data.mkdir()
lib = tmp_path / "lib"
lib.mkdir()
_image(lib / "beach.jpg", 1)
_image(lib / "city.jpg", 2)
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.services.inventory import InventoryService
config = Config.from_env(
{"PHOTO_PIPELINE_DATA_DIR": str(data), "PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib)}
)
run_migrations(config.database_url)
engine = create_db_engine(config.database_url)
InventoryService(create_session_factory(engine)).scan(lib)
engine.dispose()
port = _free_port()
env = {
"PATH": __import__("os").environ.get("PATH", ""),
"PHOTO_PIPELINE_DATA_DIR": str(data),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
"PHOTO_PIPELINE_HOST": "127.0.0.1",
"PHOTO_PIPELINE_PORT": str(port),
}
proc = subprocess.Popen(
[sys.executable, "-m", "photo_pipeline", "serve"],
cwd=str(REPO),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
base = f"http://127.0.0.1:{port}"
deadline = time.monotonic() + 30
ready = False
while time.monotonic() < deadline:
if proc.poll() is not None:
out, err = proc.communicate()
pytest.fail(f"server exited: {err.decode(errors='replace')}")
try:
if httpx.get(f"{base}/api/v1/health/ready", timeout=1).status_code == 200:
ready = True
break
except httpx.HTTPError:
time.sleep(0.2)
if not ready:
proc.terminate()
pytest.fail("server never became ready")
try:
yield SimpleNamespace(base=base)
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
def test_js_unit_suite_passes(page, server):
page.goto(f"{server.base}/app/js/tests/harness.html")
page.locator("#results").wait_for()
results = page.evaluate("window.__RESULTS__")
failing = [c["name"] for c in results["cases"] if not c["ok"]]
assert results["failed"] == 0, f"JS unit failures: {failing}"
assert results["passed"] >= 12
def test_shell_loads_assets_without_console_or_network_errors(page, server):
errors, failed = [], []
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
page.on("requestfailed", lambda r: failed.append(r.url))
page.goto(f"{server.base}/app/#/inventory")
page.get_by_test_id("asset-row").first.wait_for()
assert page.locator("nav a[data-nav]").count() >= 2
assert errors == [], f"console errors: {errors}"
assert failed == [], f"failed requests: {failed}"
def test_deep_link_and_reload_restore_view_and_filters(page, server):
# Deep-link straight to a filtered inventory view, then reload: the hash router
# must restore both the view and the filter with no server-rendered state.
page.goto(f"{server.base}/app/#/inventory?q=beach")
search = page.get_by_label("Filter by path")
search.wait_for()
assert search.input_value() == "beach"
page.reload()
page.get_by_label("Filter by path").wait_for()
assert page.get_by_label("Filter by path").input_value() == "beach"
assert "q=beach" in page.evaluate("location.hash")
def test_api_is_json_only_and_html_never_intercepts(server):
# /api/v1 always answers JSON — the static SPA mount must never shadow it.
ok = httpx.get(f"{server.base}/api/v1/inventory/assets", timeout=5)
assert ok.headers["content-type"].startswith("application/json")
missing = httpx.get(f"{server.base}/api/v1/does-not-exist", timeout=5)
assert missing.status_code == 404
assert missing.headers["content-type"].startswith("application/json")
shell = httpx.get(f"{server.base}/app/", timeout=5)
assert shell.headers["content-type"].startswith("text/html")