/** * Covers the event streams that keep an open drill-down page current: what a * page is sent when it connects, what a serving tick sends it, and which pages * are rendered at all. */ import { describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; import { LIVE_PAGE_CLIENT, livePage, type LivePageEvent, livePageResponse, livePages, } from "./live-page.ts"; import { LIVE_PAGE_UPDATE } from "./live-page-client.ts"; import { faviconHref } from "./favicon.ts"; import type { Route } from "./types.ts"; import { SERVING_VERSION } from "./version.ts"; const decoder = new TextDecoder(); /** A live route whose markup is whatever `markup` holds when it renders. */ function counter(path = "/counted") { const state = { markup: "one", renders: 0, urls: [] as string[] }; const route: Route = { path, live: true, handler: (_req, url) => { state.renders++; state.urls.push(url.href); return new Response(`
${state.markup}
`); }, }; return { state, route }; } /** Opens the stream for `page` and reads it one event at a time. */ function watch(pages: ReturnType, page: string) { const response = pages.open( new URL(`http://dashboard/events?page=${encodeURIComponent(page)}`), ); const reader = response.body!.getReader(); return { response, async next(): Promise { const { value, done } = await reader.read(); if (done) throw new Error("the event stream ended"); return decoder.decode(value); }, close: () => reader.cancel(), }; } /** The markup a page event carries, failing on any other event. */ function markupOf(event: string): LivePageEvent { expect(event).toMatch(/^event: page\n/); return JSON.parse(event.match(/^data: (.*)$/m)![1]); } describe("livePages()", () => { it("sends a page its markup and the serving version as it connects", async () => { const { route } = counter(); const page = watch(livePages([route], "v1"), "/counted"); expect(page.response.headers.get("content-type")).toBe("text/event-stream"); expect(await page.next()).toBe(": connected\n\n"); expect(markupOf(await page.next())).toEqual({ version: "v1", html: "
one
", }); await page.close(); }); it("sends a tick's rendering only when the markup changed", async () => { const { state, route } = counter(); const pages = livePages([route], "v1"); const page = watch(pages, "/counted"); await page.next(); await page.next(); await pages.tick(); expect(await page.next()).toMatch(/^event: ping\ndata: 1\n\n$/); expect(state.renders).toBe(2); state.markup = "two"; await pages.tick(); expect(await page.next()).toMatch(/^event: ping\ndata: 2\n/); expect(markupOf(await page.next()).html).toBe("
two
"); await page.close(); }); it("sends a second page the markup the first already holds, and the first nothing", async () => { const { route } = counter(); const pages = livePages([route], "v1"); const first = watch(pages, "/counted"); await first.next(); await first.next(); const second = watch(pages, "/counted"); await second.next(); expect(markupOf(await second.next()).html).toBe("
one
"); await pages.tick(); // The heartbeat is the next thing the first page hears, not a copy of // the markup it has. expect(await first.next()).toMatch(/^event: ping\n/); await first.close(); await second.close(); }); it("renders the page each stream names, with its query", async () => { const { state, route } = counter(); const page = watch(livePages([route], "v1"), "/counted?repo=loom"); await page.next(); await page.next(); expect(state.urls).toEqual(["http://dashboard/counted?repo=loom"]); await page.close(); }); it("stops rendering a page once the last browser showing it has gone", async () => { const { state, route } = counter(); const pages = livePages([route], "v1"); const page = watch(pages, "/counted"); await page.next(); await page.next(); await page.close(); await pages.tick(); expect(state.renders).toBe(1); }); it("refuses a stream for a route that is not live, or not a route", async () => { const { route } = counter(); const still: Route = { ...route, path: "/still", live: undefined }; const pages = livePages([route, still], "v1"); for ( const page of [ "/still", "/nowhere", "//elsewhere.example/counted", "http://[", "", ] ) { const response = pages.open( new URL(`http://dashboard/events?page=${encodeURIComponent(page)}`), ); expect(response.status).toBe(404); await response.body?.cancel(); } }); it("applies later renderings while one never finishes, and drops one that finishes late", async () => { const { state, route } = counter(); const held: Array<(response: Response) => void> = []; let hold = false; const slow: Route = { ...route, handler: (req, url) => hold ? new Promise((resolve) => held.push(resolve)) : route.handler(req, url), }; const pages = livePages([slow], "v1"); const page = watch(pages, "/counted"); await page.next(); await page.next(); hold = true; await Promise.race([pages.tick(), Promise.resolve()]); expect(await page.next()).toMatch(/^event: ping\n/); hold = false; state.markup = "two"; await pages.tick(); expect(await page.next()).toMatch(/^event: ping\n/); expect(markupOf(await page.next()).html).toBe("
two
"); held[0](new Response("
stale
")); await pages.tick(); // The heartbeat follows the page it already holds, not the late one. expect(await page.next()).toMatch(/^event: ping\n/); state.markup = "three"; await pages.tick(); await page.next(); expect(markupOf(await page.next()).html).toBe("
three
"); await page.close(); }); it("keeps a page's stream open through a rendering that throws", async () => { const { state, route } = counter(); let fail = false; const flaky: Route = { ...route, handler: (req, url) => { if (fail) throw new Error("source unreachable"); return route.handler(req, url); }, }; const pages = livePages([flaky], "v1"); const page = watch(pages, "/counted"); await page.next(); await page.next(); const logged: unknown[][] = []; const error = console.error; console.error = (...args: unknown[]) => logged.push(args); try { fail = true; await pages.tick(); } finally { console.error = error; } expect(logged).toEqual([["live page /counted:", "source unreachable"]]); await page.next(); fail = false; state.markup = "two"; await pages.tick(); await page.next(); expect(markupOf(await page.next()).html).toBe("
two
"); await page.close(); }); }); describe("LIVE_PAGE_CLIENT", () => { it("expects the version the streams report, so it reloads on any other", async () => { const { route } = counter(); const page = watch(livePages([route]), "/counted"); await page.next(); const { version } = markupOf(await page.next()); expect(version).toBe(SERVING_VERSION); expect(LIVE_PAGE_CLIENT).toContain( `const VERSION = ${JSON.stringify(version)};`, ); await page.close(); }); it("follows the page's own stream and reloads on any other version", () => { /** The browser the script runs in, as far as the script reaches it. */ class FakeSource { readonly listeners = new Map void>(); readyState = 1; constructor(readonly url: string) { sources.push(this); } addEventListener( type: string, listener: (event: { data: string }) => void, ): void { this.listeners.set(type, listener); } close(): void {} } const sources: FakeSource[] = []; let reloads = 0; const location = { pathname: "/test-selection", search: "?x=1", reload: () => reloads++, }; const document = { getElementById: () => null, querySelector: () => null, addEventListener: () => {}, }; class DOMParser { parseFromString() { return { querySelector: () => null }; } } new Function( "document", "location", "EventSource", "DOMParser", "setInterval", "addEventListener", LIVE_PAGE_CLIENT.match(/^"); }); it("wears the status it is given as the tab's favicon, and an empty one for gray", () => { const head = (html: string) => html.slice(0, html.indexOf("")); for (const status of ["good", "warn", "bad"] as const) { expect(head(livePage({ ...content, status }))).toContain( ``, ); } for (const status of ["unknown", undefined] as const) { expect(head(livePage({ ...content, status }))).toContain( ``, ); } }); it("runs the page's own script before the client opens the stream", () => { const html = livePage({ ...content, script: "listen();" }); const own = html.indexOf(""); expect(own).toBeGreaterThan(html.indexOf("")); expect(own).toBeLessThan(html.indexOf(LIVE_PAGE_CLIENT)); }); it("answers with the page as HTML, with the status it is given", async () => { const ok = livePageResponse(content); expect(ok.status).toBe(200); expect(ok.headers.get("content-type")).toBe("text/html; charset=utf-8"); expect(await ok.text()).toBe(livePage(content)); expect(livePageResponse(content, 503).status).toBe(503); }); });