212 lines
8.5 KiB
JavaScript
212 lines
8.5 KiB
JavaScript
// 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 = "<h2>Notes</h2><h3>Notes</h3><h2>Notes</h2>";
|
|
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 =
|
|
'<a href="overview.md">Overview</a><a href="overview.md">again</a>' +
|
|
'<a href="index.md">itself</a><a href="https://example.test">out</a>';
|
|
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();
|