import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; import { embedServiceWorkerOfflineStyles } from "./vite-sw-build-id"; const uiRoot = resolve(fileURLToPath(new URL("../..", import.meta.url))); type FetchListener = (event: { request: { method: string; url: string; mode: string }; respondWith: (response: Promise | Response) => void; }) => void; function loadServiceWorkerFetchListener(overrides: { fetch: () => Promise; cachesMatch: (key: unknown) => Promise; }): FetchListener { const listeners = new Map void>(); const swSelf = { addEventListener: (type: string, listener: (event: unknown) => void) => { listeners.set(type, listener); }, skipWaiting: vi.fn(), clients: { claim: vi.fn() }, location: { origin: "https://app.example.com" }, }; const caches = { match: overrides.cachesMatch, open: vi.fn(async () => ({ put: vi.fn() })), keys: vi.fn(async () => []), delete: vi.fn(async () => true), }; // Offline fallback belongs to a stamped production build. Development // leaves Vite requests to the browser instead of intercepting them. const code = embedServiceWorkerOfflineStyles( readFileSync(resolve(uiRoot, "public/sw.js"), "utf8"), readFileSync(resolve(uiRoot, "src/index.css"), "utf8"), ) .replace("__PAPERCLIP_BUILD_ID__", "fixture-production"); new Function("self", "caches", "fetch", "Response", "URL", code)( swSelf, caches, overrides.fetch, Response, URL, ); const listener = listeners.get("fetch"); if (!listener) throw new Error("sw.js registered no fetch listener"); return listener as FetchListener; } async function respondTo( listener: FetchListener, request: { method: string; url: string; mode: string }, ): Promise { let captured: Promise | Response | undefined; listener({ request, respondWith: (response) => { captured = response; }, }); return await captured; } describe("sw.js offline fallback", () => { it("serves an uncached retry page for a failed navigation with an empty cache", async () => { const listener = loadServiceWorkerFetchListener({ fetch: () => Promise.reject(new TypeError("network down")), cachesMatch: async () => undefined, }); const response = await respondTo(listener, { method: "GET", url: "https://app.example.com/settings/instance", mode: "navigate", }); // respondWith must always receive a real Response; resolving undefined // fails the navigation with "Failed to convert value to 'Response'". expect(response).toBeInstanceOf(Response); expect(response!.status).toBe(503); expect(response!.headers.get("content-type")).toBe("text/html; charset=utf-8"); expect(response!.headers.get("cache-control")).toBe("no-store"); const body = await response!.text(); expect(body).toContain("Paperclip is offline"); expect(body).toContain("Reload page"); expect(body).toContain("