Files
clean-architecture-frontend…/tests/unit/service-worker-runtime.test.ts
T
DongHyeonkaandClaude Opus 5 976c8a8da4 fix: harden Service Worker activation and install lifecycle
SW-06: correlate activation, reset and drain replies by source object identity
against the captured waiting worker or controller, so an arbitrary same-origin
source cannot close this page's admission, and end a request immediately as
PROTOCOL_MISMATCH when the source is swapped instead of waiting for the drain
timeout. requestActivation() and resetOwnedCaches() are single-flight, so ten
concurrent callers share one nonce, listener and postMessage.

SW-07: an empty in-scope client set is vacuously drained rather than rejecting
a waiting worker when the requester already closed.

SW-08: isolate per-client postMessage failures. A client that cannot receive the
drain request fails immediately instead of holding pending state to the timeout,
skipWaiting() is the activation commit and its failure is a rejection, and the
accepted and reload notifications are sent afterwards as best effort.

SW-09: fence late install work. A fenced worker starts no new candidate work, a
late response body from a non-cooperative fetch is cancelled, a throwing digest
maps to a closed outcome, and a second exact delete of the owned candidate cache
is registered once the abandoned install settles - without extending the public
60s bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:03:07 +09:00

919 lines
29 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,
waiting,
controlled,
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 static cache authority", () => {
const setDigest = `sha256:${"c".repeat(64)}` as const;
const currentCacheName = `${STATIC_CACHE_PREFIX}${setDigest.slice(
"sha256:".length,
"sha256:".length + 16,
)}`;
function staticRuntime(
caches: Readonly<{
open: (name: string) => Promise<unknown>;
keys: () => Promise<readonly string[]>;
delete: (name: string) => Promise<boolean>;
}>,
) {
return createServiceWorkerRuntime(
{
caches,
clients: { matchAll: vi.fn(async () => []) },
registrationScope: `${ORIGIN}/app/`,
skipWaiting: vi.fn(async () => {}),
fetcher: vi.fn(),
digest: vi.fn(),
} as never,
{
identity: { ...identity, staticAssetSetDigest: setDigest },
handlers: ["PWA_STATIC_ASSETS"],
manifest: {
schemaVersion: 1,
buildId: identity.buildId,
releaseId: identity.releaseId,
setDigest,
// SW-URL-01. Generator output is root-relative.
assets: [
{
url: "/assets/app.0123456789abcdef.js",
sha256: `sha256:${"d".repeat(64)}`,
bytes: 10,
contentType: "text/javascript",
},
],
},
runtimeConfigUrl: "/runtime-config.json",
releaseManifestUrl: "/release-manifest.json",
},
);
}
it("classifies a generated root-relative asset against an absolute Request URL", async () => {
const currentResponse = new Response("current", { status: 200 });
const opened: string[] = [];
const runtime = staticRuntime({
open: vi.fn(async (name: string) => {
opened.push(name);
return {
match: async () => currentResponse.clone(),
delete: async () => true,
};
}),
keys: vi.fn(async () => [currentCacheName]),
delete: vi.fn(async () => true),
});
const served = await runtime.onFetch({
method: "GET",
url: `${ORIGIN}/assets/app.0123456789abcdef.js`,
});
expect(served).not.toBeNull();
expect(opened).toEqual([currentCacheName]);
});
it("matches static responses only in the current release cache", async () => {
const previousCacheName = `${STATIC_CACHE_PREFIX}${"e".repeat(16)}`;
const previousMatch = vi.fn(
async () => new Response("previous", { status: 200 }),
);
const deleteEntry = async (): Promise<boolean> => true;
const emptyMatch = async (): Promise<Response | undefined> => undefined;
const previousCache = { match: previousMatch, delete: deleteEntry };
const emptyCache = { match: emptyMatch, delete: deleteEntry };
const runtime = staticRuntime({
open: vi.fn(async (name: string) =>
name === previousCacheName ? previousCache : emptyCache,
),
keys: vi.fn(async () => [currentCacheName, previousCacheName]),
delete: vi.fn(async () => true),
});
// Only the previous cache holds the entry, so the request falls through to
// the network rather than serving a stale release.
await expect(
runtime.onFetch({
method: "GET",
url: `${ORIGIN}/assets/app.0123456789abcdef.js`,
}),
).resolves.toBeNull();
expect(previousMatch).not.toHaveBeenCalled();
});
it("deletes an invalid hit only from the current release cache", async () => {
const deletes: string[] = [];
const runtime = staticRuntime({
open: vi.fn(async (name: string) => {
const recordDelete = async (url: string): Promise<boolean> => {
deletes.push(`${name}:${url}`);
return true;
};
return {
match: async () => new Response("bad", { status: 500 }),
delete: recordDelete,
};
}),
keys: vi.fn(async () => [currentCacheName]),
delete: vi.fn(async () => true),
});
await expect(
runtime.onFetch({
method: "GET",
url: `${ORIGIN}/assets/app.0123456789abcdef.js`,
}),
).resolves.toBeNull();
expect(deletes).toEqual([
`${currentCacheName}:${ORIGIN}/assets/app.0123456789abcdef.js`,
]);
});
});
describe("service worker exact ownership and truthful removal", () => {
it("deletes only exact owned static cache names", async () => {
const fixture = workerScope();
fixture.scope.caches.keys = vi.fn(async () => [
`${STATIC_CACHE_PREFIX}${"a".repeat(16)}`,
`${STATIC_CACHE_PREFIX}${"b".repeat(16)}`,
// SW-02. Same prefix, not owned.
`${STATIC_CACHE_PREFIX}not-owned`,
`${STATIC_CACHE_PREFIX}${"c".repeat(17)}`,
`${STATIC_CACHE_PREFIX}${"A".repeat(16)}`,
"foreign-cache",
]);
const runtime = createServiceWorkerRuntime(fixture.scope as never, {
identity,
handlers: [],
manifest: null,
runtimeConfigUrl: "/runtime-config.json",
releaseManifestUrl: "/release-manifest.json",
});
const request = createServiceWorkerMessage({
kind: "CACHE_RESET_REQUEST",
sourceBuildId: "page-build",
targetBuildId: identity.buildId,
nonce: "nonce-reset-0001",
});
await runtime.onCacheResetRequest(request, fixture.clients[0] as never);
expect(fixture.deleted).toEqual([
`${STATIC_CACHE_PREFIX}${"a".repeat(16)}`,
`${STATIC_CACHE_PREFIX}${"b".repeat(16)}`,
]);
});
it.each([
{
label: "unregister false",
unregister: async () => false,
expected: { kind: "FAILED" },
},
{
label: "unregister rejects",
unregister: async () => {
throw new TypeError("unregister exploded");
},
expected: { kind: "FAILED" },
},
{
label: "unregister true",
unregister: async () => true,
expected: { kind: "DISABLED" },
},
])(
"does not hide $label behind DISABLED",
async ({ unregister, expected }) => {
const registration = {
scope: `${ORIGIN}/`,
installing: null,
waiting: null,
active: { scriptURL: SCRIPT_URL },
unregister: vi.fn(unregister),
update: vi.fn(async () => {}),
} as unknown as ServiceWorkerRegistration;
const controller = createServiceWorkerPageController({
container: {
controller: null,
register: vi.fn(),
getRegistration: vi.fn(async () => registration),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as never,
routerBasePath: "/",
origin: ORIGIN,
selection: {
mode: "REMOVE_REGISTRATION",
scriptPath: "service-worker.js",
handlers: [],
},
} as never);
await expect(controller.start()).resolves.toMatchObject(expected);
},
);
it("reports an ownership mismatch as INCOMPATIBLE rather than DISABLED", async () => {
const foreign = {
scope: `${ORIGIN}/`,
installing: null,
waiting: null,
active: { scriptURL: `${ORIGIN}/someone-else.js` },
unregister: vi.fn(async () => true),
update: vi.fn(async () => {}),
} as unknown as ServiceWorkerRegistration;
const controller = createServiceWorkerPageController({
container: {
controller: null,
register: vi.fn(),
getRegistration: vi.fn(async () => foreign),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as never,
routerBasePath: "/",
origin: ORIGIN,
selection: {
mode: "REMOVE_REGISTRATION",
scriptPath: "service-worker.js",
handlers: [],
},
} as never);
await expect(controller.start()).resolves.toMatchObject({
kind: "INCOMPATIBLE",
});
expect(foreign.unregister).not.toHaveBeenCalled();
});
});
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();
// SW-06. Replies are correlated by source identity, so the fake request
// comes from the registration's waiting worker.
const source = browser.waiting as unknown as {
postMessage(message: unknown): void;
};
const sourceMessages = vi.spyOn(source, "postMessage");
browser.dispatch(
createServiceWorkerMessage({
kind: "CLIENT_DRAIN_REQUEST",
sourceBuildId: "worker-build",
targetBuildId: "page-build",
nonce: "drain-1",
}),
source,
);
expect(sourceMessages).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,
}, browser.controlled ?? undefined);
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 (_name: string) => 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);
// SW-09. The public result closes at the deadline with one exact delete,
// and a second exact delete is registered once the abandoned install work
// actually settles. Both target the same owned candidate cache.
await vi.advanceTimersByTimeAsync(0);
expect(deleteCache).toHaveBeenCalledTimes(2);
expect(new Set(deleteCache.mock.calls.map((call) => call[0])).size).toBe(1);
vi.useRealTimers();
});
it("observes and cleans non-cooperative late install work", async () => {
vi.useFakeTimers();
const deleteCache = vi.fn(async (_name: string) => true);
const cancel = vi.fn(async () => {});
let releaseFetch: ((response: Response) => void) | undefined;
// A fetch that ignores the abort signal entirely.
const fetcher = vi.fn(
() =>
new Promise<Response>((resolve) => {
releaseFetch = resolve;
}),
);
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",
});
// The late response arrives after the public bound; its body is cancelled
// and no unhandled rejection escapes.
releaseFetch?.({ body: { cancel } } as unknown as Response);
await vi.advanceTimersByTimeAsync(0);
expect(cancel).toHaveBeenCalledOnce();
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();
}
});
it("accepts replies only from the captured waiting or controller source", async () => {
const browser = pageContainer({ waiting: true });
const controller = pageController(browser.container);
await controller.start();
const foreign = { postMessage: vi.fn() };
// SW-06. A same-origin but unrecognised source must not close admission.
browser.dispatch(
createServiceWorkerMessage({
kind: "CLIENT_DRAIN_REQUEST",
sourceBuildId: "worker-build",
targetBuildId: "page-build",
nonce: "drain-foreign",
}),
foreign,
);
expect(foreign.postMessage).not.toHaveBeenCalled();
await controller.stop();
});
it("coalesces concurrent activation and reset commands", async () => {
const browser = pageContainer({ waiting: true });
const controller = pageController(browser.container);
await controller.start();
// SW-06. Ten concurrent callers issue exactly one request.
const activations = Array.from({ length: 10 }, () =>
controller.requestActivation(),
);
await Promise.resolve();
expect(browser.waitingMessages).toHaveLength(1);
expect(new Set(activations).size).toBe(1);
await controller.stop();
await Promise.allSettled(activations);
});
it("ends an activation whose reply source was swapped", async () => {
const browser = pageContainer({ waiting: true });
const controller = pageController(browser.container);
await controller.start();
const activation = controller.requestActivation();
await Promise.resolve();
const request = browser.waitingMessages.at(-1) as { nonce?: string };
// A different worker answers: terminate immediately rather than waiting for
// the drain timeout.
browser.dispatch(
createServiceWorkerMessage({
kind: "ACTIVATED_RELOAD_REQUIRED",
sourceBuildId: "worker-build",
targetBuildId: "page-build",
nonce: request.nonce,
}),
{ postMessage: vi.fn() },
);
await expect(activation).resolves.toEqual({ kind: "PROTOCOL_MISMATCH" });
await controller.stop();
});
});