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