import { expect, test } from "@playwright/test"; test("M10-06 gates playback on verification and enforces cancel, LRU, restart and corruption quarantine", async ({ page }) => { test.setTimeout(120_000); await page.goto("/"); const result = await page.evaluate(async () => { const { StorageClient } = await import("/src/storage/StorageClient.ts"); const { BrowserTransformCachePlaybackSession } = await import("/src/simulation/BrowserTransformCachePlayback.ts"); const { upgradeStorageSchema } = await import("/src/storage/migrations.ts"); const digest = async (data: ArrayBuffer): Promise => Array.from( new Uint8Array(await crypto.subtle.digest("SHA-256", data)), (byte) => byte.toString(16).padStart(2, "0"), ).join(""); const errorCode = async (operation: () => Promise): Promise => { try { await operation(); return ""; } catch (error) { const code = (error as Error & { code?: unknown }).code; return typeof code === "string" ? code : (error as Error).name; } }; const projectId = `m10-06-${Date.now()}`; const migrationDatabase = `m10-06-migration-${Date.now()}`; await new Promise((resolve, reject) => { const request = indexedDB.open(migrationDatabase, 6); request.onupgradeneeded = () => { request.result.createObjectStore("migration", { keyPath: "id" }); request.result.createObjectStore("simulation_manifest", { keyPath: "id" }); }; request.onsuccess = () => { request.result.close(); resolve(); }; request.onerror = () => reject(request.error); }); const migration = await new Promise<{ version: number; stores: string[]; record?: { version: number } }>((resolve, reject) => { const request = indexedDB.open(migrationDatabase, 7); request.onupgradeneeded = (event) => upgradeStorageSchema(request.result, request.transaction!, (event as IDBVersionChangeEvent).oldVersion); request.onsuccess = () => { const database = request.result; const transaction = database.transaction("migration", "readonly"); const record = transaction.objectStore("migration").get("schema-7"); record.onsuccess = () => resolve({ version: database.version, stores: [...database.objectStoreNames], record: record.result as { version: number } | undefined }); record.onerror = () => reject(record.error); transaction.oncomplete = () => database.close(); }; request.onerror = () => reject(request.error); }); indexedDB.deleteDatabase(migrationDatabase); const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 6]).buffer; const sourceBlendSha256 = await digest(sourceBlend); const makeManifest = async (name: string, payload: ArrayBuffer, frameCount = 1) => { const frameBytes = payload.byteLength / frameCount; const binding = { graphId: `node-group:${name}`, graphHash: await digest(new TextEncoder().encode(`graph:${name}`).buffer), sourceBlendSha256, sourceRevision: 6, inputHash: await digest(new TextEncoder().encode(`input:${name}`).buffer), blenderVersion: "5.2.0", frameStart: 1, frameEnd: frameCount, }; const revisionHash = await digest(new TextEncoder().encode(JSON.stringify([ "blender-web-simulation-cache-revision-v2", binding.graphId, binding.graphHash, binding.sourceBlendSha256, String(binding.sourceRevision), binding.inputHash, binding.blenderVersion, String(binding.frameStart), String(binding.frameEnd), ])).buffer); const frames = await Promise.all(Array.from({ length: frameCount }, async (_, index) => ({ frame: index + 1, byteOffset: index * frameBytes, byteLength: frameBytes, sha256: await digest(payload.slice(index * frameBytes, (index + 1) * frameBytes)), }))); return { schemaVersion: 2 as const, ...binding, revisionHash, cacheSha256: await digest(payload), byteLength: payload.byteLength, frames, }; }; const first = new StorageClient(); await first.saveProject(projectId, 6, sourceBlend.slice(0)); const payloadA = Uint8Array.from([1, 2, 3, 4]).buffer; const manifestA = await makeManifest("lru-a", payloadA); const storedA = await first.putSimulationCache(projectId, manifestA, payloadA.slice(0)); first.terminate(); const restarted = new StorageClient(); const notReadyAfterRestart = await errorCode(() => restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, 1)); const prepared = await restarted.prepareSimulationCachePlayback(projectId, storedA.cacheKey); const recovered = await restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, 1); let cancelledPublishedFrames = 0; const playback = new BrowserTransformCachePlaybackSession({ schemaVersion: 1 as const, revision: 6, sceneId: "scene:M10-06", source: { kind: "mock" as const }, coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 }, activeObjectId: null, frame: { current: 1, start: 1, end: 1 }, nodes: [], meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [], }, { frameStart: 1, frameEnd: 1, readFrame: async (frame, signal) => (await restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, frame, signal)).data, }, () => { cancelledPublishedFrames += 1; }); const cancellingPlayback = playback.play(); playback.cancel(); const playbackCancellation = await cancellingPlayback; const pendingAfterPlaybackCancel = restarted.getPendingRequestCount(); await new Promise((resolve) => setTimeout(resolve, 5)); const payloadB = Uint8Array.from([5, 6, 7, 8]).buffer; const manifestB = await makeManifest("lru-b", payloadB); const storedB = await restarted.putSimulationCache(projectId, manifestB, payloadB.slice(0)); await new Promise((resolve) => setTimeout(resolve, 5)); const payloadC = Uint8Array.from([9, 10, 11, 12]).buffer; const manifestC = await makeManifest("lru-c", payloadC); const storedC = await restarted.putSimulationCache(projectId, manifestC, payloadC.slice(0)); const activePrune = await restarted.pruneSimulationCaches(projectId, 4); const afterActivePrune = await restarted.listSimulationCaches(projectId); await restarted.releaseSimulationCachePlayback(projectId, storedA.cacheKey); const releasedCode = await errorCode(() => restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, 1)); const finalPrune = await restarted.pruneSimulationCaches(projectId, 0); const cancelledPayload = new ArrayBuffer(256 * 32); const cancelledBytes = new Uint8Array(cancelledPayload); for (let index = 0; index < cancelledBytes.length; index += 1) cancelledBytes[index] = index % 251; const cancelledManifest = await makeManifest("cancelled", cancelledPayload, 256); const controller = new AbortController(); const cancelledWrite = restarted.putSimulationCache(projectId, cancelledManifest, cancelledPayload, controller.signal); controller.abort(); const cancelledCode = await errorCode(() => cancelledWrite); const afterCancel = await restarted.listSimulationCaches(projectId); const assetsAfterCancel = await restarted.listAssets(projectId); const corruptPayload = Uint8Array.from([31, 32, 33, 34]).buffer; const corruptManifest = await makeManifest("corrupt", corruptPayload); const corruptStored = await restarted.putSimulationCache(projectId, corruptManifest, corruptPayload.slice(0)); restarted.terminate(); const pathSegments = corruptStored.path.split("/"); const fileName = pathSegments.pop()!; let directory = await navigator.storage.getDirectory(); for (const segment of pathSegments) directory = await directory.getDirectoryHandle(segment); const handle = await directory.getFileHandle(fileName); const writable = await handle.createWritable(); await writable.write(Uint8Array.from([99, 98, 97, 96])); await writable.close(); const quarantineReader = new StorageClient(); const corruptNotReady = await errorCode(() => quarantineReader.readSimulationCacheFrame(projectId, corruptStored.cacheKey, 1)); const corruptionCode = await errorCode(() => quarantineReader.prepareSimulationCachePlayback(projectId, corruptStored.cacheKey)); const afterCorruption = await quarantineReader.listSimulationCaches(projectId); const info = await quarantineReader.info(); const pending = quarantineReader.getPendingRequestCount(); quarantineReader.terminate(); return { notReadyAfterRestart, verifiedAt: prepared.verifiedAt, recovered: Array.from(new Uint8Array(recovered.data)), playbackCancellation, cancelledPublishedFrames, pendingAfterPlaybackCancel, activePrune, activeKeys: afterActivePrune.caches.map((cache) => cache.cacheKey), expectedActiveKey: storedA.cacheKey, evictedKeys: [storedB.cacheKey, storedC.cacheKey].sort(), releasedCode, finalPrune, cancelledCode, cancelledPublished: afterCancel.caches.some((cache) => cache.cacheKey === `sim2-${cancelledManifest.revisionHash}`), cancelledAssetPresent: assetsAfterCancel.assets.some((asset) => asset.sha256 === cancelledManifest.cacheSha256), corruptNotReady, corruptionCode, cachesAfterCorruption: afterCorruption.caches.length, quarantined: afterCorruption.quarantined, issues: afterCorruption.issues, schemaVersion: info.schemaVersion, stores: info.stores, pending, migration, }; }); expect(result.notReadyAfterRestart).toBe("SIMULATION_CACHE_NOT_READY"); expect(result.verifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); expect(result.recovered).toEqual([1, 2, 3, 4]); expect(result.playbackCancellation).toEqual({ status: "CANCELLED", appliedFrames: 0, lastFrame: null }); expect(result.cancelledPublishedFrames).toBe(0); expect(result.pendingAfterPlaybackCancel).toBe(0); expect(result.activePrune).toMatchObject({ beforeBytes: 12, remainingBytes: 4, removedBytes: 8, removed: 2, budgetSatisfied: true }); expect(result.activePrune.cacheKeys.sort()).toEqual(result.evictedKeys); expect(result.activePrune.protectedCacheKeys).toContain(result.expectedActiveKey); expect(result.activeKeys).toEqual([result.expectedActiveKey]); expect(result.releasedCode).toBe("SIMULATION_CACHE_NOT_READY"); expect(result.finalPrune).toMatchObject({ beforeBytes: 4, remainingBytes: 0, removedBytes: 4, removed: 1, budgetSatisfied: true }); expect(result.cancelledCode).toBe("AbortError"); expect(result.cancelledPublished).toBe(false); expect(result.cancelledAssetPresent).toBe(false); expect(result.corruptNotReady).toBe("SIMULATION_CACHE_NOT_READY"); expect(result.corruptionCode).toBe("SIMULATION_CACHE_HASH_MISMATCH"); expect(result.cachesAfterCorruption).toBe(0); expect(result.quarantined).toBe(1); expect(result.issues).toEqual([expect.objectContaining({ code: "SIMULATION_CACHE_HASH_MISMATCH" })]); expect(result.schemaVersion).toBe(7); expect(result.stores).toContain("simulation_quarantine"); expect(result.pending).toBe(0); expect(result.migration).toMatchObject({ version: 7, record: { version: 7 } }); expect(result.migration.stores).toContain("simulation_quarantine"); });