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();
|
||||
Reference in New Issue
Block a user