import { describe, expect, it, vi } from "vitest"; import { serviceWorkerSource } from "../scripts/service-worker.mjs"; /** Run the generated worker against a fake CacheStorage and network. */ function worker(files: string[], version: string, existing: string[] = []) { const stores = new Map>(); for (const name of existing) stores.set(name, new Map()); const caches = { open: async (name: string) => { if (!stores.has(name)) stores.set(name, new Map()); const store = stores.get(name)!; const key = (request: Request | string) => new URL( typeof request === "string" ? request : request.url, "https://crystal.test/game/sw.js", ).href; return { addAll: async (urls: string[]) => { for (const url of urls) store.set(key(url), new Response(`cached ${url}`)); }, match: async ( request: Request | string, options?: { ignoreSearch?: boolean }, ) => { const url = new URL(key(request)); if (options?.ignoreSearch) url.search = ""; return store.get(url.href)?.clone(); }, }; }, keys: async () => [...stores.keys()], delete: async (name: string) => stores.delete(name), }; const fetch = vi.fn( async (request: Request) => new Response(`network ${request.url}`), ); const listeners = new Map void>(); const self = { location: new URL("https://crystal.test/game/sw.js"), addEventListener: (type: string, listener: (event: unknown) => void) => listeners.set(type, listener), clients: { claim: vi.fn(async () => {}) }, skipWaiting: vi.fn(), }; new Function("self", "caches", "fetch", serviceWorkerSource(files, version))( self, caches, fetch, ); const run = async (type: string, event: Record = {}) => { let pending: Promise = Promise.resolve(); let response: Promise | undefined; listeners.get(type)!({ ...event, waitUntil: (promise: Promise) => (pending = promise), respondWith: (promise: Promise) => (response = promise), }); await pending; return response && (await response); }; const request = ( url: string, init: { mode?: string; method?: string } = {}, ) => ({ url, method: init.method ?? "GET", mode: init.mode ?? "no-cors", }) as unknown as Request; return { stores, fetch, self, run, request }; } describe("the app shell service worker", () => { const files = [ "assets/index-abc.js", "assets/index-abc.css", "icon.svg", "index.html", "manifest.webmanifest", ]; it("precaches the shell, with the page as ./", async () => { const sw = worker(files, "v1"); await sw.run("install"); expect([...sw.stores.get("crystal-shell-v1")!.keys()].sort()).toEqual( [ "https://crystal.test/game/", "https://crystal.test/game/assets/index-abc.css", "https://crystal.test/game/assets/index-abc.js", "https://crystal.test/game/icon.svg", "https://crystal.test/game/manifest.webmanifest", ].sort(), ); }); it("serves the shell offline and leaves other requests to the network", async () => { const sw = worker(files, "v1"); await sw.run("install"); const page = await sw.run("fetch", { request: sw.request("https://crystal.test/game/?resume", { mode: "navigate", }), }); expect(await page!.text()).toBe("cached ./"); const script = await sw.run("fetch", { request: sw.request("https://crystal.test/game/assets/index-abc.js?v=2"), }); expect(await script!.text()).toBe("cached ./assets/index-abc.js"); const unknown = await sw.run("fetch", { request: sw.request("https://crystal.test/game/missing.png"), }); expect(await unknown!.text()).toBe( "network https://crystal.test/game/missing.png", ); // Other origins, other paths and writes are not intercepted at all. for (const request of [ sw.request("https://elsewhere.test/game/icon.svg"), sw.request("https://crystal.test/other/icon.svg"), sw.request("https://crystal.test/game/icon.svg", { method: "POST" }), ]) expect(await sw.run("fetch", { request })).toBeUndefined(); }); it("removes older shells when it activates and waits to take over", async () => { const sw = worker(files, "v2", ["crystal-shell-v1", "unrelated"]); await sw.run("install"); await sw.run("activate"); expect([...sw.stores.keys()].sort()).toEqual([ "crystal-shell-v2", "unrelated", ]); expect(sw.self.clients.claim).toHaveBeenCalled(); expect(sw.self.skipWaiting).not.toHaveBeenCalled(); await sw.run("message", { data: "something else" }); expect(sw.self.skipWaiting).not.toHaveBeenCalled(); await sw.run("message", { data: "skip-waiting" }); expect(sw.self.skipWaiting).toHaveBeenCalledOnce(); }); });