chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,567 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user