import { describe, expect, it, vi } from "vitest"; import { createServiceWorkerPageController } from "../../src/adapters/service-worker/service-worker-page-controller.ts"; import { createServiceWorkerRuntime } from "../../src/adapters/service-worker/service-worker-lifecycle.ts"; import { createServiceWorkerMessage } from "../../src/adapters/service-worker/service-worker-protocol.ts"; import { installStaticAssets } from "../../src/adapters/service-worker/service-worker-static-assets.ts"; import { SERVICE_WORKER_BOUNDS, SERVICE_WORKER_SCRIPT_PATH, STATIC_CACHE_PREFIX, staticCacheName, type ServiceWorkerProtocolIdentity, type StaticAssetManifestV1, } from "../../src/contracts/service-worker.ts"; const ORIGIN = "https://app.example"; const SCRIPT_URL = `${ORIGIN}/service-worker.js`; function pageContainer(options: { waiting?: boolean; controlled?: boolean } = {}) { const listeners = new Set<(event: MessageEvent) => void>(); const waitingMessages: unknown[] = []; const controllerMessages: unknown[] = []; const worker = (messages: unknown[]) => ({ scriptURL: SCRIPT_URL, postMessage(message: unknown) { messages.push(message); }, }) as unknown as ServiceWorker; const waiting = options.waiting ? worker(waitingMessages) : null; const active = options.waiting ? null : worker([]); const controlled = options.controlled ? worker(controllerMessages) : null; const registration = { scope: `${ORIGIN}/`, installing: null, waiting, active, update: vi.fn(async () => {}), } as unknown as ServiceWorkerRegistration; const container = { controller: controlled, register: vi.fn(async () => registration), addEventListener(_type: string, listener: (event: MessageEvent) => void) { listeners.add(listener); }, removeEventListener(_type: string, listener: (event: MessageEvent) => void) { listeners.delete(listener); }, } as unknown as ServiceWorkerContainer; return { container, registration, waitingMessages, controllerMessages, listenerCount: () => listeners.size, dispatch(data: unknown, source?: { postMessage(message: unknown): void }) { const event = { data, origin: ORIGIN, source } as unknown as MessageEvent; for (const listener of [...listeners]) listener(event); }, }; } function pageController(container: ServiceWorkerContainer, blockers = [() => false]) { return createServiceWorkerPageController({ selection: { mode: "ACTIVE", scriptPath: SERVICE_WORKER_SCRIPT_PATH, handlers: [], }, disabledCleanup: false, routerBasePath: "/", origin: ORIGIN, buildId: "page-build", container, blockers, }); } const identity: ServiceWorkerProtocolIdentity = { serviceWorkerProtocolVersion: 1, cacheSchemaVersion: 1, buildId: "worker-build", releaseId: "release-1", contractSetDigest: `sha256:${"1".repeat(64)}`, staticAssetSetDigest: `sha256:${"2".repeat(64)}`, }; function workerScope() { const deleted: string[] = []; const clients = ["client-a", "client-b"].map((id) => ({ id, url: `${ORIGIN}/app/${id}`, messages: [] as unknown[], postMessage(message: unknown) { this.messages.push(message); }, })); const scope = { caches: { open: vi.fn(), keys: vi.fn(async () => [ `${STATIC_CACHE_PREFIX}${"a".repeat(16)}`, `${STATIC_CACHE_PREFIX}${"b".repeat(16)}`, "foreign-cache", ]), delete: vi.fn(async (name: string) => { deleted.push(name); return true; }), match: vi.fn(), }, clients: { matchAll: vi.fn(async () => clients) }, registrationScope: `${ORIGIN}/app/`, skipWaiting: vi.fn(async () => {}), fetcher: vi.fn(), digest: vi.fn(), }; return { scope, clients, deleted }; } describe("service worker page protocol", () => { it("does not attach late listeners when stopped during registration", async () => { const browser = pageContainer(); let completeRegistration: ((value: ServiceWorkerRegistration) => void) | undefined; const deferredRegistration = new Promise((resolve) => { completeRegistration = resolve; }); vi.mocked(browser.container.register).mockReturnValue(deferredRegistration); const controller = pageController(browser.container); const starting = controller.start(); await Promise.resolve(); await controller.stop(); completeRegistration?.(browser.registration); await expect(starting).resolves.toEqual({ kind: "FAILED", code: "STOPPED" }); expect(browser.listenerCount()).toBe(0); }); it("answers a worker drain request only after local blockers are clear", async () => { const browser = pageContainer({ waiting: true }); const controller = pageController(browser.container); await controller.start(); const source = { postMessage: vi.fn() }; browser.dispatch( createServiceWorkerMessage({ kind: "CLIENT_DRAIN_REQUEST", sourceBuildId: "worker-build", targetBuildId: "page-build", nonce: "drain-1", }), source, ); expect(source.postMessage).toHaveBeenCalledWith( expect.objectContaining({ kind: "CLIENT_DRAINED", sourceBuildId: "page-build", targetBuildId: "worker-build", nonce: "drain-1", }), ); await controller.stop(); }); it("settles a pending activation and removes its listener when stopped", async () => { const browser = pageContainer({ waiting: true }); const controller = pageController(browser.container); await controller.start(); const activation = controller.requestActivation(); await Promise.resolve(); expect(browser.listenerCount()).toBe(2); await controller.stop(); await expect(activation).resolves.toEqual({ kind: "FAILED", code: "STOPPED" }); expect(browser.listenerCount()).toBe(0); }); it("waits for the correlated cache reset result", async () => { const browser = pageContainer({ controlled: true }); const controller = pageController(browser.container); await controller.start(); let settled = false; const result = controller.resetOwnedCaches().then((outcome) => { settled = true; return outcome; }); await Promise.resolve(); expect(settled).toBe(false); const request = browser.controllerMessages.at(-1) as { nonce?: string }; browser.dispatch({ ...createServiceWorkerMessage({ kind: "CACHE_RESET_RESULT", sourceBuildId: "worker-build", targetBuildId: "page-build", nonce: request.nonce, }), cachesDeleted: 2, }); await expect(result).resolves.toEqual({ kind: "RESET", cachesDeleted: 2 }); await controller.stop(); }); }); describe("service worker worker-side protocol", () => { it("retains the immediately previous verified static cache on activation", async () => { const current = staticCacheName(identity.staticAssetSetDigest); const stale = `${STATIC_CACHE_PREFIX}${"3".repeat(16)}`; const previous = `${STATIC_CACHE_PREFIX}${"4".repeat(16)}`; const names = [stale, previous, current, "foreign-cache"]; const deleted: string[] = []; const markerPut = vi.fn(async () => {}); const cache = { match: vi.fn(async () => undefined), put: markerPut, delete: vi.fn(async () => true), } as unknown as Cache; const scope = { caches: { open: vi.fn(async () => cache), keys: vi.fn(async () => names), delete: vi.fn(async (name: string) => { deleted.push(name); return true; }), match: vi.fn(), }, clients: { matchAll: vi.fn(async () => []) }, skipWaiting: vi.fn(async () => {}), fetcher: vi.fn(), digest: vi.fn(), }; const runtime = createServiceWorkerRuntime(scope as never, { identity, handlers: ["PWA_STATIC_ASSETS"], manifest: { schemaVersion: 1, buildId: identity.buildId, releaseId: identity.releaseId, setDigest: identity.staticAssetSetDigest as `sha256:${string}`, assets: [], }, runtimeConfigUrl: "/runtime-config.json", releaseManifestUrl: "/release-manifest.json", }); await expect(runtime.onActivate()).resolves.toBe(1); expect(deleted).toEqual([stale]); expect(markerPut).toHaveBeenCalledOnce(); }); it("waits for every exact client drain acknowledgement before activation", async () => { const fixture = workerScope(); const runtime = createServiceWorkerRuntime(fixture.scope as never, { identity, handlers: [], manifest: null, runtimeConfigUrl: "/runtime-config.json", releaseManifestUrl: "/release-manifest.json", }); const request = createServiceWorkerMessage({ kind: "ACTIVATE_REQUEST", sourceBuildId: "page-build", targetBuildId: "worker-build", nonce: "activation-1", }); const activation = runtime.onActivateRequest(request); await Promise.resolve(); expect(fixture.scope.skipWaiting).not.toHaveBeenCalled(); runtime.onClientMessage( createServiceWorkerMessage({ kind: "CLIENT_DRAINED", sourceBuildId: "page-build", targetBuildId: "worker-build", nonce: "activation-1", }), "client-a", ); await Promise.resolve(); expect(fixture.scope.skipWaiting).not.toHaveBeenCalled(); runtime.onClientMessage( createServiceWorkerMessage({ kind: "CLIENT_DRAINED", sourceBuildId: "page-build", targetBuildId: "worker-build", nonce: "activation-1", }), "client-b", ); await expect(activation).resolves.toBe("ACCEPTED"); expect(fixture.scope.skipWaiting).toHaveBeenCalledTimes(1); }); it("enumerates uncontrolled window clients before an activation drain", async () => { const fixture = workerScope(); const runtime = createServiceWorkerRuntime(fixture.scope as never, { identity, handlers: [], manifest: null, runtimeConfigUrl: "/runtime-config.json", releaseManifestUrl: "/release-manifest.json", }); const request = createServiceWorkerMessage({ kind: "ACTIVATE_REQUEST", sourceBuildId: "page-build", targetBuildId: "worker-build", nonce: "activation-uncontrolled", }); const activation = runtime.onActivateRequest(request); await Promise.resolve(); for (const client of fixture.clients) { runtime.onClientMessage( createServiceWorkerMessage({ kind: "CLIENT_DRAINED", sourceBuildId: "page-build", targetBuildId: "worker-build", nonce: "activation-uncontrolled", }), client.id, ); } await expect(activation).resolves.toBe("ACCEPTED"); expect(fixture.scope.clients.matchAll).toHaveBeenCalledWith({ type: "window", includeUncontrolled: true, }); }); it("drains only page clients inside the exact registration scope", async () => { const inScope = { id: "client-in-scope", url: `${ORIGIN}/app/nested/page`, messages: [] as unknown[], postMessage(message: unknown) { this.messages.push(message); }, }; const outOfScope = { id: "client-out-of-scope", url: `${ORIGIN}/application/page`, messages: [] as unknown[], postMessage(message: unknown) { this.messages.push(message); }, }; const fixture = workerScope(); fixture.scope.clients.matchAll.mockResolvedValue([ inScope, outOfScope, ]); const runtime = createServiceWorkerRuntime(fixture.scope as never, { identity, handlers: [], manifest: null, runtimeConfigUrl: "/runtime-config.json", releaseManifestUrl: "/release-manifest.json", }); const activation = runtime.onActivateRequest( createServiceWorkerMessage({ kind: "ACTIVATE_REQUEST", sourceBuildId: "page-build", targetBuildId: "worker-build", nonce: "activation-scope", }), ); await Promise.resolve(); runtime.onClientMessage( createServiceWorkerMessage({ kind: "CLIENT_DRAINED", sourceBuildId: "page-build", targetBuildId: "worker-build", nonce: "activation-scope", }), inScope.id, ); runtime.onClientMessage( createServiceWorkerMessage({ kind: "CLIENT_DRAINED", sourceBuildId: "page-build", targetBuildId: "worker-build", nonce: "activation-scope", }), outOfScope.id, ); await expect(activation).resolves.toBe("ACCEPTED"); expect(inScope.messages).not.toHaveLength(0); expect(outOfScope.messages).toEqual([]); }); it("deletes only owned caches and returns the correlated reset count", async () => { const fixture = workerScope(); const runtime = createServiceWorkerRuntime(fixture.scope as never, { identity, handlers: [], manifest: null, runtimeConfigUrl: "/runtime-config.json", releaseManifestUrl: "/release-manifest.json", }); const source = { id: "client-a", url: `${ORIGIN}/app/client-a`, postMessage: vi.fn(), }; await runtime.onCacheResetRequest( createServiceWorkerMessage({ kind: "CACHE_RESET_REQUEST", sourceBuildId: "page-build", targetBuildId: "worker-build", nonce: "reset-1", }), source, ); expect(fixture.deleted).toEqual([ `${STATIC_CACHE_PREFIX}${"a".repeat(16)}`, `${STATIC_CACHE_PREFIX}${"b".repeat(16)}`, ]); expect(source.postMessage).toHaveBeenCalledWith( expect.objectContaining({ kind: "CACHE_RESET_RESULT", nonce: "reset-1", cachesDeleted: 2, }), ); }); }); describe("service worker static asset install", () => { const manifest: StaticAssetManifestV1 = { schemaVersion: 1, buildId: "worker-build", releaseId: "release-1", setDigest: `sha256:${"a".repeat(64)}`, assets: [ { url: `${ORIGIN}/assets/app.js`, sha256: `sha256:${"b".repeat(64)}`, bytes: 1, contentType: "application/javascript", }, ], }; it("aborts and rolls back a candidate cache at the overall install deadline", async () => { vi.useFakeTimers(); const deleteCache = vi.fn(async () => true); const fetcher = vi.fn( (_input: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { init?.signal?.addEventListener("abort", () => { reject(new DOMException("Install deadline", "AbortError")); }); }), ); const result = installStaticAssets(manifest, { caches: { open: vi.fn(async () => ({ put: vi.fn() }) as unknown as Cache), delete: deleteCache, }, fetcher: fetcher as typeof fetch, digest: vi.fn(), }); await vi.advanceTimersByTimeAsync(SERVICE_WORKER_BOUNDS.installDeadlineMs); await expect(result).resolves.toEqual({ kind: "REJECTED", code: "INSTALL_DEADLINE_EXCEEDED", }); expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); expect(deleteCache).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); it("stops reading as soon as the streamed body exceeds declared bytes", async () => { const put = vi.fn(async () => {}); const cancel = vi.fn(async () => {}); const body = new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array([1, 2])); }, cancel, }); await expect( installStaticAssets(manifest, { caches: { open: vi.fn(async () => ({ put }) as unknown as Cache), delete: vi.fn(async () => true), }, fetcher: vi.fn(async () => new Response(body, { status: 200, headers: { "content-type": "application/javascript" }, }), ), digest: vi.fn(), }), ).resolves.toEqual({ kind: "REJECTED", code: "BYTES_MISMATCH" }); expect(put).not.toHaveBeenCalled(); expect(cancel).toHaveBeenCalledTimes(1); }); it("aborts sibling asset fetches after the first install failure", async () => { vi.useFakeTimers(); try { let siblingSignal: AbortSignal | undefined; const twoAssetManifest: StaticAssetManifestV1 = { ...manifest, assets: [ manifest.assets[0]!, { url: `${ORIGIN}/assets/chunk.js`, sha256: `sha256:${"c".repeat(64)}`, bytes: 1, contentType: "application/javascript", }, ], }; const fetcher = vi.fn( async (input: RequestInfo | URL, init?: RequestInit) => { if (String(input).endsWith("app.js")) { return new Response(null, { status: 500 }); } siblingSignal = init?.signal ?? undefined; await new Promise((resolve) => setTimeout(resolve, 10)); return new Response(new Uint8Array([1]), { status: 200, headers: { "content-type": "application/javascript" }, }); }, ); const installing = installStaticAssets(twoAssetManifest, { caches: { open: vi.fn(async () => ({ put: vi.fn() }) as unknown as Cache), delete: vi.fn(async () => true), }, fetcher: fetcher as typeof fetch, digest: vi.fn(async () => twoAssetManifest.assets[1]!.sha256), }); await vi.advanceTimersByTimeAsync(10); await expect(installing).resolves.toEqual({ kind: "REJECTED", code: "STATUS_INVALID", }); expect(siblingSignal?.aborted).toBe(true); } finally { vi.useRealTimers(); } }); });