Files
clean-architecture-frontend…/tests/unit/service-worker-runtime.test.ts
T
2026-08-01 19:39:59 +09:00

568 lines
18 KiB
TypeScript

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<ServiceWorkerRegistration>((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<Response>((_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<Uint8Array>({
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<void>((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();
}
});
});