313 lines
9.8 KiB
TypeScript
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);
|
|
});
|