import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; // Vitest runs with the webui/ directory as the working directory. const SW_SCRIPT = readFileSync(resolve(process.cwd(), "public/sw.js"), "utf8"); const ORIGIN = "https://nanobot.test"; const CACHE_NAME = "nanobot-static-v2"; /** Minimal Cache-compatible in-memory store with SW-style URL normalization. */ class FakeCacheStore { entries = new Map(); private key(input: Request | string): string { return new URL(typeof input === "string" ? input : input.url, ORIGIN).href; } async addAll(urls: string[]): Promise { for (const url of urls) { this.entries.set(this.key(url), new Response("ok")); } } async match(input: Request | string): Promise { return this.entries.get(this.key(input)); } async put(input: Request | string, response: Response): Promise { this.entries.set(this.key(input), response); } async delete(input: Request | string): Promise { return this.entries.delete(this.key(input)); } async keys(): Promise { return [...this.entries.keys()].map((url) => new Request(url)); } } interface LoadedSw { store: FakeCacheStore; deletedCacheNames: string[]; fetchMock: ReturnType; skipWaitingMock: ReturnType; claimMock: ReturnType; fire: (type: string, event?: Record) => Promise; } function loadSw(): LoadedSw { const listeners = new Map) => void>>(); const skipWaitingMock = vi.fn(); const claimMock = vi.fn(); const self = { location: { origin: ORIGIN }, skipWaiting: skipWaitingMock, clients: { claim: claimMock }, addEventListener: (type: string, cb: (event: Record) => void) => { listeners.set(type, [...(listeners.get(type) ?? []), cb]); }, }; const store = new FakeCacheStore(); const deletedCacheNames: string[] = []; const caches = { open: vi.fn(async () => store), match: vi.fn((input: Request | string) => store.match(input)), keys: vi.fn(async () => [CACHE_NAME, "nanobot-static-v1", "other-app-cache"]), delete: vi.fn(async (name: string) => { deletedCacheNames.push(name); return true; }), }; const fetchMock = vi.fn(); // Execute the plain public/sw.js script inside a controlled scope so the // service worker globals (self/caches/fetch) are fully mocked. new Function("self", "caches", "fetch", SW_SCRIPT)(self, caches, fetchMock); const fire = async (type: string, event: Record = {}) => { for (const cb of listeners.get(type) ?? []) { let waitPromise: Promise = Promise.resolve(); const wrapped = { ...event, waitUntil: (promise: Promise) => { waitPromise = promise; }, }; await cb(wrapped); await waitPromise; } }; return { store, deletedCacheNames, fetchMock, skipWaitingMock, claimMock, fire }; } function indexHtml(assetPaths: string[]): Response { const refs = assetPaths .map( (path) => ``, ) .join(""); return new Response(`${refs}`); } function fetchEvent(url: string) { const respondWith = vi.fn(); const event = { request: new Request(url, { method: "GET" }), respondWith, }; return { event, respondWith }; } describe("service worker", () => { beforeEach(() => { vi.restoreAllMocks(); }); it("precaches the shell and manifest on install", async () => { const sw = loadSw(); await sw.fire("install"); expect(sw.skipWaitingMock).toHaveBeenCalledTimes(1); expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true); expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).toBe(true); expect(sw.store.entries.has(`${ORIGIN}/asset-manifest.json`)).toBe(true); }); it("removes stale cache names and prunes unreferenced static entries on activate", async () => { const sw = loadSw(); await sw.fire("install"); // Simulate the latest loaded build: index.html references only v2 assets. await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js", "/assets/index-v2.css"])); await sw.store.put(`${ORIGIN}/assets/index-v2.js`, new Response("v2")); await sw.store.put(`${ORIGIN}/assets/index-v2.css`, new Response("v2 css")); await sw.store.put(`${ORIGIN}/assets/lazy-v2.js`, new Response("lazy v2")); await sw.store.put(`${ORIGIN}/assets/index-v1.js`, new Response("v1")); await sw.store.put(`${ORIGIN}/assets/index-v1.css`, new Response("v1 css")); await sw.store.put(`${ORIGIN}/asset-manifest.json`, new Response(JSON.stringify({ "src/main.tsx": { file: "assets/index-v2.js", css: ["assets/index-v2.css"], }, "src/lazy.tsx": { file: "assets/lazy-v2.js", isDynamicEntry: true }, }))); await sw.fire("activate"); expect(sw.deletedCacheNames).toEqual(["nanobot-static-v1"]); expect(sw.claimMock).toHaveBeenCalledTimes(1); expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true); expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).toBe(true); expect(sw.store.entries.has(`${ORIGIN}/asset-manifest.json`)).toBe(true); expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.js`)).toBe(true); expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.css`)).toBe(true); expect(sw.store.entries.has(`${ORIGIN}/assets/lazy-v2.js`)).toBe(true); expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.js`)).toBe(false); expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.css`)).toBe(false); }); it("does not intercept dynamic or unknown endpoint requests", async () => { const sw = loadSw(); const paths = [ "/api/v1/models", "/auth/session", "/__nanobot/ws", "/__nanobot/socket.io", "/api/chat/completions", "/webui/bootstrap", "/webui/session/list", "/custom-token", "/mcp-oauth/callback", ]; for (const path of paths) { const { respondWith } = fetchEvent(`${ORIGIN}${path}`); await sw.fire("fetch", { request: new Request(`${ORIGIN}${path}`) }); expect(respondWith, path).not.toHaveBeenCalled(); } expect(sw.fetchMock).not.toHaveBeenCalled(); }); it("does not intercept non-GET or cross-origin requests", async () => { const sw = loadSw(); const { respondWith: post } = fetchEvent(`${ORIGIN}/api/v1/models`); await sw.fire("fetch", { request: new Request(`${ORIGIN}/api/v1/models`, { method: "POST" }), }); expect(post).not.toHaveBeenCalled(); const { respondWith: cross } = fetchEvent("https://other.test/assets/app.js"); await sw.fire("fetch", { request: new Request("https://other.test/assets/app.js"), }); expect(cross).not.toHaveBeenCalled(); expect(sw.fetchMock).not.toHaveBeenCalled(); }); it("serves cached static assets without touching the network", async () => { const sw = loadSw(); const assetUrl = `${ORIGIN}/assets/index-a1b2c3.js`; await sw.store.put(assetUrl, new Response("cached js")); const { event, respondWith } = fetchEvent(assetUrl); await sw.fire("fetch", event); const response = (await respondWith.mock.calls[0][0]) as Response; expect(response).toBeDefined(); expect(await response.text()).toBe("cached js"); expect(sw.fetchMock).not.toHaveBeenCalled(); }); it("fetches static assets on miss and caches the response, passing the original request", async () => { const sw = loadSw(); const assetUrl = `${ORIGIN}/assets/index-x9y8z7.js`; const originalRequest = new Request(assetUrl); sw.fetchMock.mockResolvedValue(new Response("fresh js")); const event = { request: originalRequest, respondWith: vi.fn() }; await sw.fire("fetch", event); const response = (await event.respondWith.mock.calls[0][0]) as Response; expect(await response.text()).toBe("fresh js"); // The exact request object is handed to fetch() — credentials mode and // other properties are preserved, never reconstructed. expect(sw.fetchMock).toHaveBeenCalledWith(originalRequest); expect(sw.store.entries.has(assetUrl)).toBe(true); }); it.each(["private", "no-cache", "no-store"])( "does not cache a %s response even under the static asset prefix", async (cacheControl) => { const sw = loadSw(); const tokenUrl = `${ORIGIN}/assets/custom-token`; const originalRequest = new Request(tokenUrl); sw.fetchMock.mockResolvedValue( new Response('{"token":"short-lived"}', { headers: { "Cache-Control": cacheControl, "Content-Type": "application/json", }, }), ); const event = { request: originalRequest, respondWith: vi.fn() }; await sw.fire("fetch", event); const response = (await event.respondWith.mock.calls[0][0]) as Response; expect(await response.json()).toEqual({ token: "short-lived" }); expect(sw.store.entries.has(tokenUrl)).toBe(false); }, ); it("keeps un-hashed brand assets on the network-first path", async () => { const sw = loadSw(); const iconUrl = `${ORIGIN}/brand/nanobot_icon_192.png`; const originalRequest = new Request(iconUrl); sw.fetchMock.mockResolvedValue(new Response("png bytes")); const event = { request: originalRequest, respondWith: vi.fn() }; await sw.fire("fetch", event); const response = (await event.respondWith.mock.calls[0][0]) as Response; expect(await response.text()).toBe("png bytes"); // Unlike hashed /assets/ files, brand icons are fetched every load so a // future icon swap reaches installed clients. expect(sw.fetchMock).toHaveBeenCalledWith(originalRequest); expect(sw.store.entries.has(iconUrl)).toBe(true); }); it("clones network responses before yielding their body to the browser", async () => { const sw = loadSw(); const request = new Request(`${ORIGIN}/brand/nanobot_icon_192.png`); let browserOwnsBody = false; let clonedBeforeBrowser = false; const response = { ok: true, clone: vi.fn(() => { clonedBeforeBrowser = !browserOwnsBody; return new Response("cached png bytes"); }), } as unknown as Response; sw.fetchMock.mockResolvedValue(response); const respondWith = vi.fn((pending: Promise) => { void pending.then(() => { browserOwnsBody = true; }); }); await sw.fire("fetch", { request, respondWith }); expect(clonedBeforeBrowser).toBe(true); expect(response.clone).toHaveBeenCalledTimes(1); }); it("serves navigation network-first, prunes on shell refresh, and falls back when offline", async () => { const sw = loadSw(); await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js"])); // Assets from an older build, still referenced by the cached v2 shell. await sw.store.put(`${ORIGIN}/assets/index-v2.js`, new Response("v2")); await sw.store.put(`${ORIGIN}/assets/index-v1.js`, new Response("v1")); // Offline: network rejects, cached shell is returned. sw.fetchMock.mockRejectedValue(new TypeError("Failed to fetch")); const offlineEvent = { request: new Request(`${ORIGIN}/`), respondWith: vi.fn(), }; await sw.fire("fetch", offlineEvent); const offlineResponse = (await offlineEvent.respondWith.mock.calls[0][0]) as Response; expect(await offlineResponse.text()).toContain("assets/index-v2.js"); // Online: network response wins, refreshes the cached shell, and prunes // entries the new index.html no longer references. const freshShell = indexHtml(["/assets/index-v3.js"]); sw.fetchMock.mockImplementation((input: Request | string) => { const url = new URL(typeof input === "string" ? input : input.url, ORIGIN); if (url.pathname === "/asset-manifest.json") { return Promise.resolve(new Response(JSON.stringify({ "src/main.tsx": { file: "assets/index-v3.js" }, }))); } return Promise.resolve(freshShell.clone()); }); const onlineEvent = { request: new Request(`${ORIGIN}/`), respondWith: vi.fn(), }; await sw.fire("fetch", onlineEvent); const onlineResponse = (await onlineEvent.respondWith.mock.calls[0][0]) as Response; expect(await onlineResponse.text()).toContain("assets/index-v3.js"); // The prune is fire-and-forget; give its microtasks a chance to settle. await new Promise((resolve) => setTimeout(resolve, 0)); expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true); expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.js`)).toBe(false); expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.js`)).toBe(false); }); it("serves the cached app shell for offline deep-link navigations", async () => { const sw = loadSw(); await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js"])); sw.fetchMock.mockRejectedValue(new TypeError("Failed to fetch")); // happy-dom's Request ignores `mode: "navigate"` in the constructor; // force it so the service worker treats this as a navigation. const request = new Request(`${ORIGIN}/settings`); Object.defineProperty(request, "mode", { value: "navigate" }); const event = { request, respondWith: vi.fn() }; await sw.fire("fetch", event); const response = (await event.respondWith.mock.calls[0][0]) as Response; expect(await response.text()).toContain("assets/index-v2.js"); }); });