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();