WP-01: a CAS receipt is only evidence when it names the expected key and the
exact next revision. Write and remove now share one validator, so a stale or
arbitrary repository receipt can no longer be packaged as a confirmed control.
WP-05: a pre-aborted command records the operation the caller requested instead
of always reporting an inspection.
WP-06: bounded fan-out is reported honestly. The subscriptionchange client
handoff and the notification cleanup both emit countBucket and truncated, and an
incomplete cleanup returns { complete: false } and is observed as DEGRADED
separately from revoke authority.
WP-07: the user-visible native notification effect is tracked through
NOT_APPLIED, MAYBE_APPLIED and CONFIRMED phases and surfaced as observation
evidence, never as retry authorization.
WP-02, WP-03 and WP-04 stay open: they need the V2 wire protocol with server
request-shape negotiation, which belongs to the versioned-migration task rather
than this correctness pass. The ledger records them as DEFERRED_TO_MIGRATION.
Web Push remains NOT_SELECTED and AVAILABLE_NOT_COMPOSED.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
811 lines
25 KiB
TypeScript
811 lines
25 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 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();
|
|
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();
|
|
}
|
|
});
|
|
});
|