import { expect, test } from "@playwright/test"; import path from "node:path"; const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend"); test("completes the V1 import-edit-save-restart-reopen-GLB loop", async ({ page }) => { test.setTimeout(120_000); await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend)); const result = await page.evaluate(async (input) => { const [{ WebEngineClient }, { StorageClient }, { ViewportRenderer }, { OffscreenViewportRenderer }] = await Promise.all([ import("/src/engine-client/WebEngineClient.ts"), import("/src/storage/StorageClient.ts"), import("/src/three-adapter/viewport.ts"), import("/src/three-adapter/offscreen-viewport.ts"), ]); const projectId = `v1-loop-${Date.now()}-${Math.random().toString(16).slice(2)}`; const digestGeometry = async (buffers: Array<{ meshId: string; positions: ArrayBuffer; indices: ArrayBuffer }>, meshId: string): Promise => { const geometry = buffers.find((candidate) => candidate.meshId === meshId); if (!geometry) throw new Error(`missing geometry ${meshId}`); const bytes = new Uint8Array(geometry.positions.byteLength + geometry.indices.byteLength); bytes.set(new Uint8Array(geometry.positions)); bytes.set(new Uint8Array(geometry.indices), geometry.positions.byteLength); const digest = await crypto.subtle.digest("SHA-256", bytes); return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""); }; const materialByName = (snapshot: { materials: Array<{ name: string; roughness: number; metallic: number; baseColor: number[] }> }, name: string) => snapshot.materials.find((material) => material.name === name); const firstEngine = new WebEngineClient({ timeoutMs: 30_000 }); const firstStorage = new StorageClient(); let editedRevision = 0; let objectId = ""; let meshId = ""; let materialId = ""; let editedGeometrySha256 = ""; let storedRevision = 0; try { const opened = await firstEngine.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength)); const created = await firstEngine.applyCommand({ type: "createPrimitive", primitive: "CUBE", name: "V1LoopCube", location: [2, 0, 0] }); const object = created.snapshot.nodes.find((node) => node.name === "V1LoopCube"); if (!object?.dataId) throw new Error("V1 object creation did not return stable object/mesh IDs"); objectId = object.id; meshId = object.dataId; await firstEngine.applyCommand({ type: "setObjectTransform", objectId, translation: [2, 1, 0.5], rotationEuler: [0.1, 0.2, 0.3], scale: [1.25, 0.75, 1.5] }); await firstEngine.applyCommand({ type: "translateMeshVertices", meshId, vertexIndices: [0, 1], offset: [0, 0, 0.25] }); const slotted = await firstEngine.applyCommand({ type: "addMaterialSlot", objectId, name: "V1LoopMaterial" }); const material = materialByName(slotted.snapshot, "V1LoopMaterial"); if (!material) throw new Error("V1 material slot did not create a material"); materialId = (material as { id?: string }).id ?? ""; if (!materialId) throw new Error("V1 material has no stable ID"); const materialEdited = await firstEngine.applyCommand({ type: "setMaterialPrincipled", materialId, baseColor: [0.12, 0.34, 0.56, 1], roughness: 0.23, metallic: 0.67, }); const undone = await firstEngine.applyCommand({ type: "undo" }); const undoMaterial = materialByName(undone.snapshot, "V1LoopMaterial"); const redone = await firstEngine.applyCommand({ type: "redo" }); const redoMaterial = materialByName(redone.snapshot, "V1LoopMaterial"); if (!undoMaterial || !redoMaterial || undoMaterial.roughness === redoMaterial.roughness) throw new Error("V1 material undo/redo did not change Main state"); if (materialEdited.snapshot.revision >= redone.snapshot.revision) throw new Error("V1 history revisions did not advance"); editedRevision = redone.snapshot.revision; editedGeometrySha256 = await digestGeometry(redone.geometryBuffers, meshId); const unsupported = await firstEngine.queryRenderCapability({ kind: "ARBITRARY_SHADER", nodeTypes: ["UNSUPPORTED"] }); if (unsupported.status !== "BLOCKED" || unsupported.issues[0]?.code !== "SHADER_NODE_UNSUPPORTED") throw new Error("V1 unsupported shader gate drifted"); const saved = await firstEngine.saveBlend(); const stored = await firstStorage.saveProject(projectId, editedRevision, saved.slice(0)); await firstStorage.saveSnapshot(projectId, editedRevision, saved.slice(0)); storedRevision = stored.revision; } finally { firstEngine.terminate(); firstStorage.terminate(); } const restartedStorage = new StorageClient(); const stored = await restartedStorage.readProject(projectId); restartedStorage.terminate(); const restartedEngine = new WebEngineClient({ timeoutMs: 30_000 }); const reopened = await restartedEngine.openBlend(stored.buffer); restartedEngine.terminate(); const reopenedObject = reopened.snapshot.nodes.find((node) => node.id === objectId); const reopenedMaterial = reopened.snapshot.materials.find((material) => material.id === materialId); const reopenedGeometrySha256 = await digestGeometry(reopened.geometryBuffers, meshId); if (!reopenedObject || !reopenedMaterial) throw new Error("V1 reopened scene lost edited IDs"); const createCanvas = (): HTMLCanvasElement => { const canvas = document.createElement("canvas"); canvas.width = 320; canvas.height = 240; canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px"; document.body.append(canvas); return canvas; }; const mainCanvas = createCanvas(); const main = new ViewportRenderer(mainCanvas); main.setSnapshot(reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? []); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const gl = main.renderer.getContext(); const mainPixels = new Uint8Array(32 * 32 * 4); gl.readPixels(0, 0, 32, 32, gl.RGBA, gl.UNSIGNED_BYTE, mainPixels); const mainVisible = Array.from({ length: 32 * 32 }, (_, index) => mainPixels[index * 4] + mainPixels[index * 4 + 1] + mainPixels[index * 4 + 2] > 30).filter(Boolean).length; main.dispose(); mainCanvas.remove(); const offscreenCanvas = createCanvas(); const offscreen = new OffscreenViewportRenderer(offscreenCanvas); offscreen.setSnapshot(reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? []); const deadline = performance.now() + 30_000; while (Number(offscreenCanvas.dataset.rendererPixels ?? 0) === 0 && !offscreenCanvas.dataset.rendererError) { if (performance.now() > deadline) throw new Error("V1 Offscreen viewport timed out"); await new Promise((resolve) => setTimeout(resolve, 25)); } const offscreenVisible = Number(offscreenCanvas.dataset.rendererPixels ?? 0); const offscreenError = offscreenCanvas.dataset.rendererError; offscreen.dispose(); offscreenCanvas.remove(); const glb = await new Promise<{ ok: boolean; byteLength: number; roundTrip?: { compatible: boolean; mismatches: string[] }; pbrSummary?: { baseColorFactor?: number[]; roughnessFactor?: number; metallicFactor?: number }; error?: string }>((resolve, reject) => { const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" }); worker.onmessage = (event) => { worker.terminate(); resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const transfer: Transferable[] = []; for (const geometry of reopened.geometryBuffers) { for (const value of Object.values(geometry)) if (value instanceof ArrayBuffer) transfer.push(value); } worker.postMessage({ snapshot: reopened.snapshot, geometryBuffers: reopened.geometryBuffers, assetBuffers: [] }, transfer); }); return { editedRevision, storedRevision, persistedRevision: stored.revision, reopenedRevision: reopened.snapshot.revision, objectTransform: reopenedObject.transform, material: reopenedMaterial, geometry: [editedGeometrySha256, reopenedGeometrySha256], viewport: { mainVisible, offscreenVisible, offscreenError }, glb, }; }, new Uint8Array(bytes)); expect(result.editedRevision).toBeGreaterThan(1); expect(result.storedRevision).toBe(result.editedRevision); expect(result.persistedRevision).toBe(result.editedRevision); expect(result.reopenedRevision).toBe(1); expect(result.objectTransform.translation).toEqual([2, 1, 0.5]); expect(result.objectTransform.scale).toEqual([1.25, 0.75, 1.5]); [0.12, 0.34, 0.56, 1].forEach((value, index) => expect(result.material.baseColor[index]).toBeCloseTo(value, 6)); expect(result.material.roughness).toBeCloseTo(0.23); expect(result.material.metallic).toBeCloseTo(0.67); expect(result.geometry[0]).toMatch(/^[a-f0-9]{64}$/); expect(result.geometry[1]).toBe(result.geometry[0]); expect(result.viewport.mainVisible).toBeGreaterThan(0); expect(result.viewport.offscreenVisible).toBeGreaterThan(0); expect(result.viewport.offscreenError).toBeUndefined(); expect(result.glb.ok).toBe(true); expect(result.glb.byteLength).toBeGreaterThan(1_000); expect(result.glb.roundTrip).toEqual({ compatible: true, mismatches: [] }); [0.12, 0.34, 0.56, 1].forEach((value, index) => expect(result.glb.pbrSummary?.baseColorFactor?.[index]).toBeCloseTo(value, 6)); expect(result.glb.pbrSummary?.roughnessFactor).toBeCloseTo(0.23); expect(result.glb.pbrSummary?.metallicFactor).toBeCloseTo(0.67); });