Files

313 lines
9.8 KiB
TypeScript

import { expect, test } from "../support/browser/strict-browser-test.ts";
test("keeps A active across a failed B stage, activates B with A rollback, and cleans only owned stale caches", async ({
page,
}) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const adapterModulePath =
"/src/adapters/cache-storage/public-response-cache-adapter.ts";
const policyModulePath =
"/src/adapters/cache-storage/public-cache-policy.ts";
const {
computePublicCacheManifestDigestHex,
createPublicCacheWebLock,
createPublicResponseCacheAdapter,
} = (await import(
/* @vite-ignore */ adapterModulePath
)) as typeof import("../../src/adapters/cache-storage/public-response-cache-adapter.ts");
const { createDefaultPublicCachePolicy } = (await import(
/* @vite-ignore */ policyModulePath
)) as typeof import("../../src/adapters/cache-storage/public-cache-policy.ts");
const suffix = crypto.randomUUID().replaceAll("-", "");
const releaseAId = `browser-a-${suffix.slice(0, 20)}`;
const releaseBId = `browser-b-${suffix.slice(0, 20)}`;
const unrelatedName = `unrelated-${suffix}`;
const policy = {
...createDefaultPublicCachePolicy(location.origin),
ownedCachePrefix: `cap-${suffix.slice(0, 24)}:`,
mutationLockName: `cap-${suffix}:mutation`,
};
const payloads = {
[`${location.origin}/cache-${suffix}-a.js`]: {
bytes: new TextEncoder().encode("verified-release-a"),
contentType: "application/javascript",
},
[`${location.origin}/cache-${suffix}-b.js`]: {
bytes: new TextEncoder().encode("verified-release-b"),
contentType: "application/javascript",
},
[`${location.origin}/cache-${suffix}-b.css`]: {
bytes: new TextEncoder().encode(".release-b{display:block}"),
contentType: "text/css",
},
} as const;
const [aUrl, bUrl, bSecondUrl] = Object.keys(payloads);
const digestHex = async (bytes: Uint8Array): Promise<string> => {
const digest = await crypto.subtle.digest(
"SHA-256",
Uint8Array.from(bytes),
);
return Array.from(
new Uint8Array(digest),
(byte) => byte.toString(16).padStart(2, "0"),
).join("");
};
const asset = async (absoluteUrl: string) => {
const payload = payloads[absoluteUrl as keyof typeof payloads];
return {
absoluteUrl,
expectedByteLength: payload.bytes.byteLength,
expectedContentType: payload.contentType,
integrity: {
algorithm: "SHA-256" as const,
digestHex: await digestHex(payload.bytes),
},
};
};
const assetsA = [await asset(aUrl!)];
const assetsB = [
await asset(bUrl!),
await asset(bSecondUrl!),
];
const manifestA = {
releaseRegistryId: releaseAId,
assets: assetsA,
manifestDigestHex:
await computePublicCacheManifestDigestHex(
crypto,
releaseAId,
assetsA,
policy,
),
};
const manifestB = {
releaseRegistryId: releaseBId,
assets: assetsB,
manifestDigestHex:
await computePublicCacheManifestDigestHex(
crypto,
releaseBId,
assetsB,
policy,
),
};
let corruptSecondBAsset = true;
const adapter = createPublicResponseCacheAdapter({
cacheStorage: caches,
crypto,
mutationLock: createPublicCacheWebLock(
navigator.locks,
policy.mutationLockName,
),
policy,
fetcher: async (request) => {
if (request.signal.aborted) {
throw new DOMException("Aborted.", "AbortError");
}
const payload =
payloads[request.url as keyof typeof payloads];
if (!payload) throw new TypeError("Unexpected cache URL.");
const responseBytes =
corruptSecondBAsset && request.url === bSecondUrl
? new Uint8Array(payload.bytes.byteLength)
: Uint8Array.from(payload.bytes);
return new Response(responseBytes, {
status: 200,
headers: {
"cache-control":
"public, max-age=31536000, immutable",
"content-type": payload.contentType,
"x-account-id": "must-not-persist",
},
});
},
});
const readActive = async (absoluteUrl: string) => {
const matched = await adapter.responses.matchActiveExact({
absoluteUrl,
});
if (!matched.ok || !matched.value) return matched;
const streamed: number[] = [];
for await (const chunk of matched.value.body.stream(
new AbortController().signal,
)) {
if (!chunk.ok) return chunk;
streamed.push(...chunk.value);
}
return {
ok: true as const,
streamed,
headerNames: matched.value.headers.map(([name]) => name),
};
};
try {
const stageA = await adapter.admin.stageRelease(manifestA);
const activateA = await adapter.admin.activateRelease(
manifestA.releaseRegistryId,
manifestA.manifestDigestHex,
);
const matchedABeforeFailedB = await readActive(aUrl!);
const failedStageB =
await adapter.admin.stageRelease(manifestB);
const afterFailedB = await adapter.admin.inspect();
const matchedAAfterFailedB = await readActive(aUrl!);
corruptSecondBAsset = false;
const stageB = await adapter.admin.stageRelease(manifestB);
const activateB = await adapter.admin.activateRelease(
manifestB.releaseRegistryId,
manifestB.manifestDigestHex,
);
const matchedB = await readActive(bUrl!);
const afterActivateB = await adapter.admin.inspect();
const releaseBCacheName = (await caches.keys()).find((name) =>
name.startsWith(
`${policy.ownedCachePrefix}release:${releaseBId}:`,
),
);
if (!releaseBCacheName) {
throw new Error("B release cache not found.");
}
const releaseBCache = await caches.open(releaseBCacheName);
await releaseBCache.put(
new Request(bUrl!),
new Response(new Uint8Array([0]), {
status: 200,
headers: {
"cache-control": "public",
"content-type": "application/javascript",
},
}),
);
const poisoned = await adapter.responses.matchActiveExact({
absoluteUrl: bUrl!,
});
await caches.open(`${policy.ownedCachePrefix}stale`);
await caches.open(unrelatedName);
const cleanup = await adapter.admin.cleanupOwned();
const names = await caches.keys();
return {
stageA,
activateA,
matchedABeforeFailedB,
failedStageB,
afterFailedB,
matchedAAfterFailedB,
stageB,
activateB,
matchedB,
afterActivateB,
poisoned,
cleanup,
unrelatedPreserved: names.includes(unrelatedName),
activeBPreserved: names.some((name) =>
name.startsWith(
`${policy.ownedCachePrefix}release:${releaseBId}:`,
),
),
rollbackAPreserved: names.some((name) =>
name.startsWith(
`${policy.ownedCachePrefix}release:${releaseAId}:`,
),
),
remainingOwnedCount: names.filter((name) =>
name.startsWith(policy.ownedCachePrefix),
).length,
expectedABytes: [...payloads[aUrl! as keyof typeof payloads].bytes],
expectedBBytes: [...payloads[bUrl! as keyof typeof payloads].bytes],
};
} finally {
for (const name of await caches.keys()) {
if (
name.startsWith(policy.ownedCachePrefix) ||
name === unrelatedName
) {
await caches.delete(name);
}
}
}
});
expect(result.stageA).toMatchObject({
ok: true,
value: { entryCount: 1 },
});
expect(result.activateA).toMatchObject({ ok: true });
expect(result.matchedABeforeFailedB).toMatchObject({
ok: true,
streamed: result.expectedABytes,
});
expect(result.failedStageB).toMatchObject({
ok: false,
error: { code: "INTEGRITY_FAILED" },
});
expect(result.afterFailedB).toMatchObject({
ok: true,
value: {
activeReleaseRegistryId: expect.stringMatching(/^browser-a-/u),
releaseCandidates: [
expect.objectContaining({
releaseRegistryId: expect.stringMatching(/^browser-a-/u),
verified: true,
}),
],
},
});
expect(result.matchedAAfterFailedB).toMatchObject({
ok: true,
streamed: result.expectedABytes,
});
expect(result.stageB).toMatchObject({
ok: true,
value: { entryCount: 2 },
});
expect(result.activateB).toMatchObject({ ok: true });
expect(result.matchedB).toMatchObject({
ok: true,
streamed: result.expectedBBytes,
});
expect(
(
result.matchedB as Readonly<{
headerNames: readonly string[];
}>
).headerNames,
).not.toContain("x-account-id");
expect(result.afterActivateB).toMatchObject({
ok: true,
value: {
activeReleaseRegistryId: expect.stringMatching(/^browser-b-/u),
releaseCandidates: expect.arrayContaining([
expect.objectContaining({
releaseRegistryId: expect.stringMatching(/^browser-a-/u),
verified: true,
}),
expect.objectContaining({
releaseRegistryId: expect.stringMatching(/^browser-b-/u),
verified: true,
}),
]),
},
});
expect(result.poisoned).toMatchObject({
ok: false,
error: { code: "INTEGRITY_FAILED" },
});
expect(result.cleanup).toMatchObject({
ok: true,
value: { deletedOwnedCaches: 1, retainedOwnedCaches: 3 },
});
expect(result.unrelatedPreserved).toBe(true);
expect(result.activeBPreserved).toBe(true);
expect(result.rollbackAPreserved).toBe(true);
expect(result.remainingOwnedCount).toBe(3);
});