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:
@@ -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
62
frontend/js/events.js
Normal 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
23
frontend/js/store.js
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
10
frontend/js/tests/harness.html
Normal file
10
frontend/js/tests/harness.html
Normal 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
150
frontend/js/tests/unit.js
Normal 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();
|
||||
@@ -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:
|
||||
|
||||
143
tests/e2e/test_frontend_shell.py
Normal file
143
tests/e2e/test_frontend_shell.py
Normal 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")
|
||||
Reference in New Issue
Block a user