// 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"; import { assignHeadingIds, documentIndex, resolveDocLink, rewriteLinks, slug } from "../docs.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() { // The client fetches its CSRF token once, lazily (US07-02). Do that against the // real server first, so the stubbed fetch below only ever sees the call under test. await api.workflow().catch(() => {}); // ── 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 /workflow", parseHash().path === "/workflow"); } // ── 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; } // ── documentation view (US09-01) ───────────────────────────────────────── { ok("slug lowercases and dashes a heading", slug("Key Flows") === "key-flows"); ok( "slug drops punctuation but keeps words", slug("What it *will not* do:") === "what-it-will-not-do" ); ok("slug keeps non-ASCII letters", slug("Größe & Gewicht") === "größe-gewicht"); ok("slug never yields an empty anchor", slug("!!!") === "section"); const doc = document.createElement("div"); doc.innerHTML = "

Notes

Notes

Notes

"; const ids = [...assignHeadingIds(doc.querySelectorAll("h2, h3"))].map((h) => h.id); ok("repeated headings get distinct anchors", ids.join(",") === "notes,notes-2,notes-3"); ok( "a sibling document link becomes an in-app route", resolveDocLink("index", "overview.md")?.page === "overview" ); const nested = resolveDocLink("guides/install", "../overview.md#running-it"); ok("a relative link resolves against the current page", nested?.page === "overview"); ok("a link's anchor survives the rewrite", nested?.anchor === "running-it"); ok( "a bare anchor stays on the current page", resolveDocLink("overview", "#the-workflow")?.page === "overview" ); ok("an external link is left alone", resolveDocLink("index", "https://example.test/x") === null); ok("a non-markdown relative link is left alone", resolveDocLink("index", "images/a.png") === null); // A climb cannot leave the documentation tree: it is clamped at the root, so the // page it names is still fetched from under /docs and simply does not exist. ok( "a link that climbs above the docs root is clamped", resolveDocLink("index", "../../etc/passwd.md")?.page === "etc/passwd" ); ok( "a page name that is not a page name is refused", resolveDocLink("index", "..%2f..%2fetc%2fpasswd.md") === null ); const index = document.createElement("div"); index.innerHTML = 'Overviewagain' + 'itselfout'; const listed = documentIndex(rewriteLinks(index, "index")); ok( "an external link is marked safe to open away from the app", index.querySelector('a[href^="https"]').rel === "noreferrer noopener" ); ok( "an in-app link points at the docs route", index.querySelector("a").getAttribute("href") === "#/docs?page=overview" ); ok("the index lists each page once, in order", listed.length === 1); ok("the index takes its titles from the link text", listed[0].title === "Overview"); } 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();