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 => { 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); });