import { expect, test } from "@playwright/test"; test("meets the Chromium OPFS Simulation cache playback performance gate", async ({ page }) => { test.setTimeout(45_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 frameCount = 600; const frameBytes = 88; const digest = async (data: ArrayBuffer): Promise => { const hash = await crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join(""); }; const payload = new ArrayBuffer(frameCount * frameBytes); const payloadBytes = new Uint8Array(payload); const objectId = new TextEncoder().encode("object:CacheTarget"); for (let frame = 1; frame <= frameCount; frame += 1) { const offset = (frame - 1) * frameBytes; const view = new DataView(payload, offset, frameBytes); view.setUint32(0, 0x31465442, true); view.setUint16(4, 1, true); view.setUint16(6, 16, true); view.setInt32(8, frame, true); view.setUint32(12, 1, true); view.setUint8(16, objectId.length); payloadBytes.set(objectId, offset + 17); [frame / 10, 0, 0, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true)); } 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)), }))); const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 0x45, 0x52]).buffer; const fixedHash = await digest(Uint8Array.from([1, 2, 3]).buffer); const binding = { graphId: "geometry-node-tree:simulation-performance", graphHash: fixedHash, sourceBlendSha256: await digest(sourceBlend), sourceRevision: 1, inputHash: await digest(Uint8Array.from([4, 5, 6]).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 manifest = { schemaVersion: 2 as const, ...binding, revisionHash, cacheSha256: await digest(payload), byteLength: payload.byteLength, frames, }; const projectId = `simulation-performance-${Date.now()}`; const started = performance.now(); const writer = new StorageClient(); const saved = await writer.saveProject(projectId, 1, sourceBlend.slice(0)); const stored = await writer.putSimulationCache(projectId, manifest, payload); writer.terminate(); const writerPendingAfterTerminate = writer.getPendingRequestCount(); const storedAt = performance.now(); const reader = new StorageClient(); await reader.prepareSimulationCachePlayback(projectId, stored.cacheKey); const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; const scene = { schemaVersion: 1 as const, revision: 1, sceneId: "scene:CachePerformance", source: { kind: "mock" as const }, coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 }, activeObjectId: "object:CacheTarget", frame: { current: 1, start: 1, end: frameCount }, nodes: [{ id: "object:CacheTarget", name: "CacheTarget", type: "MESH" as const, parentId: null, dataId: "mesh:CacheTarget", visible: true, selectable: true, localMatrix: identity, worldMatrix: identity, transform: { translation: [0, 0, 0] as [number, number, number], rotationEuler: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number], rotationMode: 1 } }], meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [], }; let publishedFrames = 0; let lastTranslation = 0; const playback = new BrowserTransformCachePlaybackSession(scene, { frameStart: 1, frameEnd: frameCount, readFrame: async (frame, signal) => { if (signal.aborted) throw new DOMException("Playback aborted", "AbortError"); const read = await reader.readSimulationCacheFrame(projectId, stored.cacheKey, frame, signal); if (signal.aborted) throw new DOMException("Playback aborted", "AbortError"); return read.data; }, }, (preview) => { publishedFrames += 1; lastTranslation = preview.nodes[0].transform.translation[0]; }); const playbackResult = await playback.play(); await reader.releaseSimulationCachePlayback(projectId, stored.cacheKey); const finished = performance.now(); reader.terminate(); const readerPendingAfterTerminate = reader.getPendingRequestCount(); const recovery = new StorageClient(); await recovery.prepareSimulationCachePlayback(projectId, stored.cacheKey); const recovered = await recovery.readSimulationCacheFrame(projectId, stored.cacheKey, frameCount); recovery.terminate(); const recoveryPendingAfterTerminate = recovery.getPendingRequestCount(); return { backend: saved.backend, frameCount, byteLength: manifest.byteLength, status: playbackResult.status, appliedFrames: playbackResult.appliedFrames, lastFrame: playbackResult.lastFrame, publishedFrames, lastTranslation, storeMs: Math.round(storedAt - started), playbackMs: Math.round(finished - storedAt), elapsedMs: Math.round(finished - started), peakCacheBytes: manifest.byteLength, pendingRequestsAfterTerminate: writerPendingAfterTerminate + readerPendingAfterTerminate + recoveryPendingAfterTerminate, workerRestartRecovered: recovered.data.byteLength === frameBytes, }; }); console.log("simulation-cache-performance", JSON.stringify(result)); expect(result.backend).toBe("opfs"); expect(result).toMatchObject({ frameCount: 600, byteLength: 52_800, status: "COMPLETED", appliedFrames: 600, lastFrame: 600, publishedFrames: 600 }); expect(result.lastTranslation).toBeCloseTo(60, 5); expect(result.storeMs).toBeLessThan(30_000); expect(result.playbackMs).toBeLessThan(30_000); expect(result.elapsedMs).toBeLessThan(30_000); expect(result.pendingRequestsAfterTerminate).toBe(0); expect(result.workerRestartRecovered).toBe(true); });