import fs from "node:fs"; import path from "node:path"; import { expect, test } from "@playwright/test"; const root = path.resolve(import.meta.dirname, "../../.."); const glbBytes = Array.from(fs.readFileSync(path.join(root, "tests/files/web/m12_glb_desktop_v1/pbr.glb"))); const blendBytes = Array.from(fs.readFileSync(path.join(root, "tests/files/web/m12_glb_main_v1/pbr.blend"))); test("M12-06G cancels GLB import/export without publishing temporary output", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async (fixtures) => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const client = new WebEngineClient({ timeoutMs: 30_000 }); const opened = await client.openBlend(Uint8Array.from(fixtures.blend).buffer); const assets = []; for (const image of opened.snapshot.images) { const asset = await client.requestAsset(image.assetId); if (asset.status === "packed" && asset.data && asset.mimeType) assets.push({ assetId: image.assetId, data: asset.data, mimeType: asset.mimeType }); } const cancel = async (operation: "IMPORT" | "EXPORT") => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/glb-recovery-test.worker.ts", { type: "module" }); const requestId = `glb-${operation.toLowerCase()}-cancel-1`; worker.onmessage = (event: MessageEvent>) => { worker.terminate(); if (!event.data.ok) reject(new Error(String(event.data.error))); else resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const bytes = Uint8Array.from(fixtures.glb).buffer; worker.postMessage({ type: "run", requestId, operation, bytes, snapshot: operation === "EXPORT" ? opened.snapshot : undefined, geometryBuffers: operation === "EXPORT" ? opened.geometryBuffers : undefined, assetBuffers: operation === "EXPORT" ? assets : undefined, nonMeshGeometryBuffers: operation === "EXPORT" ? opened.nonMeshGeometryBuffers ?? [] : undefined, baseRevision: opened.snapshot.revision, workerGeneration: 1, }); setTimeout(() => worker.postMessage({ type: "cancel", targetRequestId: requestId }), 3); }); const imported = await cancel("IMPORT"); const exported = await cancel("EXPORT"); client.terminate(); return { imported, exported }; }, { glb: glbBytes, blend: blendBytes }); for (const operation of [result.imported, result.exported]) { expect(operation.ok).toBe(true); expect((operation.receipt as { status: string }).status).toBe("CANCELLED"); expect((operation.receipt as { errorCode: string }).errorCode).toBe("GLB_OPERATION_CANCELLED"); expect((operation.receipt as { temporaryBytes: number; liveRequests: number; committed: boolean })).toMatchObject({ temporaryBytes: 0, liveRequests: 0, committed: false }); } }); test("M12-06G recovers GLB import after Worker restart and retains old OPFS asset after quota", async ({ page }) => { await page.goto("/"); const cdp = await page.context().newCDPSession(page); await cdp.send("Storage.overrideQuotaForOrigin", { origin: new URL(page.url()).origin, quotaSize: 64 * 1024 }); const result = await page.evaluate(async (fixture) => { const { StorageClient } = await import("/src/storage/StorageClient.ts"); const { beginGLBRecoveryOperation, blockGLBRecoveryForQuota, recoverGLBRecoveryOperation } = await import("/src/testing/glb-recovery.ts"); const runWorker = (generation: number) => new Promise<{ receipt: any; result: { outputSha256: string } }>((resolve, reject) => { const worker = new Worker("/src/workers/glb-recovery-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent<{ ok: boolean; receipt?: any; result?: { outputSha256: string }; error?: string }>) => { worker.terminate(); if (!event.data.ok || !event.data.receipt || !event.data.result) reject(new Error(event.data.error ?? "GLB worker failed")); else resolve({ receipt: event.data.receipt, result: event.data.result }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ type: "run", requestId: `glb-import-generation-${generation}`, operation: "IMPORT", bytes: Uint8Array.from(fixture).buffer, baseRevision: 4, workerGeneration: generation }); }); const firstRun = await runWorker(1); const restartedRun = await runWorker(2); const recovered = recoverGLBRecoveryOperation(firstRun.receipt, 2); const projectId = `glb-recovery-${Date.now()}-${Math.random().toString(16).slice(2)}`; const storage = new StorageClient(); await storage.ensureProject(projectId); const asset = await storage.putAsset(projectId, Uint8Array.from(fixture).buffer, "model/gltf-binary", "imports/model.glb"); let quotaError = ""; let quotaReceipt; const quotaPayload = Uint8Array.from({ length: 128 * 1024 }, (_, index) => (index * 7) & 0xff).buffer; const quotaRunning = beginGLBRecoveryOperation({ operationId: "export-quota-1", operation: "EXPORT", workerGeneration: 2, baseRevision: 4, inputBytes: quotaPayload.byteLength, inputSha256: await crypto.subtle.digest("SHA-256", quotaPayload).then((digest) => Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("")) }); try { await storage.putAsset(projectId, quotaPayload, "model/gltf-binary", "imports/rejected.glb"); } catch (error) { quotaError = error instanceof Error ? error.message : String(error); quotaReceipt = blockGLBRecoveryForQuota(quotaRunning); } storage.terminate(); const restartedStorage = new StorageClient(); const restored = await restartedStorage.readAsset(projectId, asset.sha256); const assets = await restartedStorage.listAssets(projectId); restartedStorage.terminate(); return { firstHash: firstRun.result.outputSha256, restartedHash: restartedRun.result.outputSha256, recoveredStatus: recovered.status, recoveredGeneration: recovered.workerGeneration, quotaError, quotaCode: quotaReceipt?.errorCode, assetSha256: asset.sha256, restoredSha256: restored.asset.sha256, restoredBytes: restored.data.byteLength, assetCount: assets.assets.length, projectId, backendPath: asset.path, }; }, glbBytes); expect(result.firstHash).toMatch(/^[a-f0-9]{64}$/); expect(result.restartedHash).toBe(result.firstHash); expect(result.recoveredStatus).toBe("RECOVERED"); expect(result.recoveredGeneration).toBe(2); expect(result.quotaError).toMatch(/QuotaExceededError|storage quota|exceed its storage quota/i); expect(result.quotaCode).toBe("GLB_OPFS_QUOTA"); expect(result.restoredSha256).toBe(result.assetSha256); expect(result.restoredBytes).toBe(glbBytes.length); expect(result.assetCount).toBe(1); expect(result.backendPath).toMatch(/^projects\//); await cdp.send("Storage.overrideQuotaForOrigin", { origin: new URL(page.url()).origin, quotaSize: 1024 * 1024 * 1024 }); const recoveredStorage = await page.evaluate(async (projectId) => { const { StorageClient } = await import("/src/storage/StorageClient.ts"); const storage = new StorageClient(); const small = await storage.putAsset(projectId, Uint8Array.from([5, 6, 7]).buffer, "model/gltf-binary", "imports/recovery.glb"); const assets = await storage.listAssets(projectId); storage.terminate(); return { persisted: small.persisted, assetCount: assets.assets.length }; }, result.projectId); expect(recoveredStorage).toEqual({ persisted: true, assetCount: 2 }); });