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>
This commit is contained in:
2026-08-06 15:47:52 +02:00
parent de20e6c8ce
commit ce5c8db5bc
7 changed files with 437 additions and 15 deletions

View File

@@ -1,11 +1,22 @@
// Single API client: one place for fetch, JSON, and the error envelope.
const BASE = "/api/v1";
// Single API client: one place for fetch, JSON, the error envelope, and request
// cancellation. Every method accepts an optional { signal } from cancellable().
export const BASE = "/api/v1";
async function request(path, options = {}) {
const response = await fetch(BASE + path, {
headers: { "Content-Type": "application/json" },
...options,
});
async function request(path, { signal, ...options } = {}) {
let response;
try {
response = await fetch(BASE + path, {
headers: { "Content-Type": "application/json" },
signal,
...options,
});
} catch (error) {
// A caller-cancelled fetch is not a failure; tag it so views can ignore it.
if (error.name === "AbortError") {
error.code = "cancelled";
}
throw error;
}
let body = null;
try {
body = await response.json();
@@ -22,17 +33,37 @@ async function request(path, options = {}) {
return body;
}
// Cancellation: hand callers a signal to pass into any request and a cancel() to
// abort the in-flight fetch. Aborted requests reject with code "cancelled".
export function cancellable() {
const controller = new AbortController();
return { signal: controller.signal, cancel: () => controller.abort() };
}
export const api = {
listAssets: (params = {}) =>
request("/inventory/assets?" + new URLSearchParams(params).toString()),
listClusters: (params = {}) =>
request("/duplicates/clusters?" + new URLSearchParams(params).toString()),
getCluster: (id) => request(`/duplicates/clusters/${encodeURIComponent(id)}`),
decide: (id, payload) =>
listAssets: (params = {}, opts = {}) =>
request("/inventory/assets?" + new URLSearchParams(params).toString(), opts),
listClusters: (params = {}, opts = {}) =>
request("/duplicates/clusters?" + new URLSearchParams(params).toString(), opts),
getCluster: (id, opts = {}) =>
request(`/duplicates/clusters/${encodeURIComponent(id)}`, opts),
decide: (id, payload, opts = {}) =>
request(`/duplicates/clusters/${encodeURIComponent(id)}/decision`, {
method: "POST",
body: JSON.stringify(payload),
...opts,
}),
thumbnailUrl: (assetId, size = 512) =>
`${BASE}/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`,
// ── Jobs ────────────────────────────────────────────────────────────────
startJob: (payload, opts = {}) =>
request("/jobs", { method: "POST", body: JSON.stringify(payload), ...opts }),
getJob: (id, opts = {}) => request(`/jobs/${encodeURIComponent(id)}`, opts),
cancelJob: (id, opts = {}) =>
request(`/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST", ...opts }),
jobEvents: (id, after = 0, opts = {}) =>
request(`/jobs/${encodeURIComponent(id)}/events?after=${after}`, opts),
jobEventsStreamUrl: (id, after = 0) =>
`${BASE}/jobs/${encodeURIComponent(id)}/events/stream?after=${after}`,
};

62
frontend/js/events.js Normal file
View File

@@ -0,0 +1,62 @@
// Job activity adapter: prefer SSE (resumable, pushed), fall back to polling the
// same durable events when EventSource is missing or the stream errors. The cursor
// is the event `seq`, shared by both transports, so switching mid-stream never
// drops or repeats an event. subscribeJob returns a stop() handle.
import { api } from "./api.js";
// Mirrors ACTIVE_STATES in services/jobs.py; a job outside this set is terminal
// and the poller stops.
const ACTIVE = new Set(["queued", "running", "cancelling", "retry_queued"]);
export function subscribeJob(jobId, { onEvent, onDone, onError, after = 0, pollMs = 1000 } = {}) {
let cursor = after;
let stopped = false;
let source = null;
let timer = null;
function stop() {
if (stopped) return;
stopped = true;
if (source) source.close();
if (timer) clearTimeout(timer);
if (onDone) onDone(cursor);
}
async function poll() {
if (stopped) return;
try {
const data = await api.jobEvents(jobId, cursor);
for (const event of data.events) {
cursor = event.seq;
if (onEvent) onEvent(event);
}
if (!ACTIVE.has(data.state)) return stop();
} catch (error) {
if (!stopped && onError) onError(error);
}
if (!stopped) timer = setTimeout(poll, pollMs);
}
if (typeof EventSource === "function") {
source = new EventSource(api.jobEventsStreamUrl(jobId, cursor));
source.onmessage = (message) => {
const event = JSON.parse(message.data);
cursor = Number(message.lastEventId) || cursor;
if (onEvent) onEvent({ seq: cursor, ...event });
};
// Server sends `event: done` when the job reaches a terminal state.
source.addEventListener("done", stop);
// Network/stream error: drop SSE and continue from the same cursor via polling
// so no consumer notices the transport switch.
source.onerror = () => {
if (stopped || !source) return;
source.close();
source = null;
poll();
};
} else {
poll();
}
return { stop };
}

23
frontend/js/store.js Normal file
View File

@@ -0,0 +1,23 @@
// Shared observable store: one state object, subscribe for changes, set() to
// patch. Views read state and re-render on notify. Nothing here touches the URL
// (the router owns navigable state) or the network (the api client owns that) —
// this is only the in-memory glue between them.
export function createStore(initial = {}) {
let state = { ...initial };
const listeners = new Set();
function set(patch) {
const next = typeof patch === "function" ? patch(state) : patch;
state = { ...state, ...next };
for (const listener of listeners) listener(state);
}
return {
get: () => state,
set,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}

View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Shell unit tests</title>
</head>
<body>
<script type="module" src="./unit.js"></script>
</body>
</html>

150
frontend/js/tests/unit.js Normal file
View File

@@ -0,0 +1,150 @@
// In-browser unit tests for the shell primitives. No server, no JS toolchain:
// globals (fetch, EventSource) are stubbed and the modules are exercised directly.
// Driven by tests/e2e/test_frontend_shell.py, which loads harness.html and reads
// window.__RESULTS__. Covers API errors + cancellation, store transitions,
// routing, and the SSE→polling event fallback (US02-05 acceptance).
import { createStore } from "../store.js";
import { parseHash, navigate } from "../router.js";
import { api, cancellable } from "../api.js";
import { subscribeJob } from "../events.js";
const cases = [];
function ok(name, cond) {
cases.push({ name, ok: !!cond });
}
async function throws(name, fn, predicate) {
try {
await fn();
cases.push({ name, ok: false });
} catch (error) {
cases.push({ name, ok: !!predicate(error) });
}
}
const realFetch = window.fetch;
const realES = window.EventSource;
function jsonResponse(status, body) {
return { ok: status < 400, status, statusText: "", json: async () => body };
}
const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms));
async function run() {
// ── store ────────────────────────────────────────────────────────────────
{
const store = createStore({ n: 0 });
const seen = [];
const off = store.subscribe((s) => seen.push(s.n));
store.set({ n: 1 });
store.set((s) => ({ n: s.n + 1 }));
ok("store applies object and updater patches", store.get().n === 2);
ok("store notifies each change", seen.length === 2 && seen[1] === 2);
off();
store.set({ n: 99 });
ok("store unsubscribe stops notifications", seen.length === 2);
}
// ── router ───────────────────────────────────────────────────────────────
{
navigate("/inventory", { q: "beach", offset: 0, empty: "" });
const { path, params } = parseHash();
ok("router restores path", path === "/inventory");
ok("router restores non-empty filters", params.q === "beach" && params.offset === "0");
ok("router drops empty filters", !("empty" in params));
location.hash = "";
ok("router defaults to /inventory", parseHash().path === "/inventory");
}
// ── api errors + cancellation ────────────────────────────────────────────
{
window.fetch = async () =>
jsonResponse(404, { error: { code: "not_found", message: "unknown job x" } });
await throws(
"api surfaces the error envelope",
() => api.getJob("x"),
(e) => e.status === 404 && e.code === "not_found" && e.message === "unknown job x"
);
window.fetch = async () => {
const err = new Error("aborted");
err.name = "AbortError";
throw err;
};
const { signal, cancel } = cancellable();
cancel();
await throws(
"api tags aborted requests as cancelled",
() => api.getJob("x", { signal }),
(e) => e.code === "cancelled"
);
window.fetch = realFetch;
}
// ── event fallback: polling when EventSource is unavailable ───────────────
{
window.EventSource = undefined;
const pages = [
jsonResponse(200, { state: "running", events: [{ seq: 1, type: "queued", message: null }] }),
jsonResponse(200, {
state: "succeeded",
events: [{ seq: 2, type: "state:succeeded", message: null }],
}),
];
let call = 0;
window.fetch = async () => pages[Math.min(call++, pages.length - 1)];
const got = [];
await new Promise((resolve) => {
subscribeJob("job1", {
pollMs: 1,
onEvent: (e) => got.push(e.seq),
onDone: resolve,
});
});
ok("polling delivers events in order", got.join(",") === "1,2");
ok("polling stops on terminal state", call >= 2);
window.fetch = realFetch;
}
// ── event stream: SSE path, then done ─────────────────────────────────────
{
class FakeES {
constructor() {
this.listeners = {};
setTimeout(() => {
this.onmessage?.({ lastEventId: "5", data: JSON.stringify({ type: "state:running", message: "go" }) });
this.listeners.done?.();
}, 0);
}
addEventListener(type, fn) {
this.listeners[type] = fn;
}
close() {
this.closed = true;
}
}
window.EventSource = FakeES;
let polled = false;
window.fetch = async () => {
polled = true;
return jsonResponse(200, { state: "succeeded", events: [] });
};
const got = [];
await new Promise((resolve) => {
subscribeJob("job2", { onEvent: (e) => got.push(e), onDone: resolve });
});
ok("SSE delivers event with seq from lastEventId", got.length === 1 && got[0].seq === 5);
ok("SSE carries type from payload", got[0].type === "state:running");
ok("SSE path does not poll", polled === false);
window.fetch = realFetch;
window.EventSource = realES;
}
await tick();
const failed = cases.filter((c) => !c.ok);
window.__RESULTS__ = { passed: cases.length - failed.length, failed: failed.length, cases };
const pre = document.createElement("pre");
pre.id = "results";
pre.textContent = cases.map((c) => `${c.ok ? "PASS" : "FAIL"} ${c.name}`).join("\n");
document.body.appendChild(pre);
}
run();

View File

@@ -102,10 +102,13 @@ async def stream_events(job_id: str, request: Request, after: int = 0):
for _ in range(600):
for event in service.events_after(job_id, cursor):
cursor = event["seq"]
# Default (unnamed) event so a browser EventSource receives every
# event via onmessage without enumerating the open-ended type set
# (state:*, claimed, …); the type travels in the JSON payload,
# matching the polling fallback's {seq, type, message} shape.
yield (
f"id: {event['seq']}\n"
f"event: {event['type']}\n"
f"data: {json.dumps({'message': event['message']})}\n\n"
f"data: {json.dumps({'type': event['type'], 'message': event['message']})}\n\n"
)
snapshot = service.get(job_id)
if snapshot is None or snapshot["state"] not in ACTIVE_STATES:

View File

@@ -0,0 +1,143 @@
"""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")