The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1057 lines
33 KiB
TypeScript
1057 lines
33 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();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* SW-01. The marker read must be bounded in bytes, not only in logic. Adding a
|
|
* whole chunk and *then* comparing the running total meant a corrupt body could
|
|
* hand activation a 1 MiB chunk against a 257-byte ceiling, and the reader lock
|
|
* was never released.
|
|
*/
|
|
describe("SW-01 the activation marker read is bounded in bytes", () => {
|
|
function scopeWithMarkerBody(
|
|
body: ReadableStream<Uint8Array> | null,
|
|
headers: Readonly<Record<string, string>> = {},
|
|
) {
|
|
const markerUrl = "__service-worker-activation-v1__";
|
|
const response = body
|
|
? new Response(body, { status: 200, headers })
|
|
: new Response("", { status: 200, headers });
|
|
const cache = {
|
|
match: vi.fn(async (request: RequestInfo | URL) =>
|
|
String(request).includes(markerUrl) ? response : undefined,
|
|
),
|
|
put: vi.fn(async () => {}),
|
|
delete: vi.fn(async () => true),
|
|
} as unknown as Cache;
|
|
return {
|
|
response,
|
|
scope: {
|
|
caches: {
|
|
open: vi.fn(async () => cache),
|
|
// The current static cache must exist for its marker to be read.
|
|
keys: vi.fn(async () => [
|
|
staticCacheName(identity.staticAssetSetDigest),
|
|
]),
|
|
delete: vi.fn(async () => true),
|
|
match: vi.fn(),
|
|
},
|
|
clients: { matchAll: vi.fn(async () => []) },
|
|
skipWaiting: vi.fn(async () => {}),
|
|
fetcher: vi.fn(),
|
|
digest: vi.fn(),
|
|
},
|
|
};
|
|
}
|
|
|
|
const runtimeFor = (scope: unknown) =>
|
|
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",
|
|
});
|
|
|
|
it("never retains a single chunk larger than the marker ceiling", async () => {
|
|
let delivered = 0;
|
|
let cancels = 0;
|
|
const oversized = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
delivered += 1;
|
|
controller.enqueue(new Uint8Array(1_048_576));
|
|
},
|
|
cancel() {
|
|
cancels += 1;
|
|
},
|
|
});
|
|
const fixture = scopeWithMarkerBody(oversized);
|
|
|
|
// Activation still completes; the marker is simply not admitted.
|
|
await expect(runtimeFor(fixture.scope).onActivate()).resolves.toBeTypeOf(
|
|
"number",
|
|
);
|
|
// Every oversized chunk that arrived was refused before being retained,
|
|
// and the reader that saw it was cancelled. The marker is probed once per
|
|
// candidate cache, so the counts track each other rather than a constant.
|
|
expect(cancels).toBeGreaterThanOrEqual(1);
|
|
expect(delivered).toBeLessThanOrEqual(2);
|
|
expect(fixture.response.body?.locked).toBe(false);
|
|
});
|
|
|
|
it("bounds a stream that never produces a chunk", async () => {
|
|
let cancels = 0;
|
|
const stalled = new ReadableStream<Uint8Array>({
|
|
pull() {
|
|
return new Promise<void>(() => {});
|
|
},
|
|
cancel() {
|
|
cancels += 1;
|
|
},
|
|
});
|
|
const fixture = scopeWithMarkerBody(stalled);
|
|
|
|
await expect(
|
|
runtimeFor(fixture.scope).onActivate(),
|
|
).resolves.toBeTypeOf("number");
|
|
expect(cancels).toBeGreaterThanOrEqual(1);
|
|
}, 10_000);
|
|
|
|
it("cancels the body it refuses for a declared oversize", async () => {
|
|
let cancels = 0;
|
|
const declared = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
controller.enqueue(new Uint8Array(8));
|
|
},
|
|
cancel() {
|
|
cancels += 1;
|
|
},
|
|
});
|
|
const fixture = scopeWithMarkerBody(declared, {
|
|
"content-length": "1048576",
|
|
});
|
|
|
|
await expect(
|
|
runtimeFor(fixture.scope).onActivate(),
|
|
).resolves.toBeTypeOf("number");
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(cancels).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("refuses a marker body that is not valid UTF-8", async () => {
|
|
const invalid = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new Uint8Array([0xff, 0xfe, 0xfd]));
|
|
controller.close();
|
|
},
|
|
});
|
|
const fixture = scopeWithMarkerBody(invalid);
|
|
|
|
await expect(
|
|
runtimeFor(fixture.scope).onActivate(),
|
|
).resolves.toBeTypeOf("number");
|
|
expect(fixture.response.body?.locked).toBe(false);
|
|
});
|
|
});
|