import { expect, test } from "@playwright/test"; import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; const root = path.resolve(import.meta.dirname, "../../.."); const inspect = `import bpy,json r={"frame":bpy.context.scene.frame_current,"objects":sorted((o.name,o.type,list(o.location)) for o in bpy.context.scene.objects),"meshes":sorted((m.name,len(m.vertices),len(m.polygons),[list(v.co) for v in list(m.vertices)[:10]]) for m in bpy.data.meshes),"modifiers":sorted((o.name,[(m.name,m.type,m.show_viewport) for m in o.modifiers]) for o in bpy.context.scene.objects),"images":sorted((i.name,bool(i.packed_file),list(i.size)) for i in bpy.data.images if i.type not in {'RENDER_RESULT','COMPOSITING'}),"materials":sorted(m.name for m in bpy.data.materials),"collections":sorted((c.name,sorted(o.name for o in c.objects)) for c in bpy.data.collections)} r["particles"]=sorted((o.name,[(p.name,p.settings.name,p.settings.type,p.settings.count) for p in o.particle_systems]) for o in bpy.context.scene.objects) print("ROUNDTRIP_JSON="+json.dumps(r,sort_keys=True))`; function desktop(file: string) { const output = execFileSync(path.join(root, "build_blender_native/bin/blender"), ["--background", file, "--python-exit-code", "1", "--python-expr", inspect], { encoding: "utf8", timeout: 30_000, maxBuffer: 1024 * 1024 }); const line = output.split("\n").find((line) => line.startsWith("ROUNDTRIP_JSON=")); if (!line) throw new Error(`Missing native inspection result: ${output.slice(-2000)}`); return JSON.parse(line.slice("ROUNDTRIP_JSON=".length)); } for (const fixture of ["WBV2-P2-003-MESH.EDIT_CORE.blend", "WBV2-P4-001-MODIFIER.DECLARED_SLICES.blend", "WBV2P3005Library.blend"]) { test(`WASM edited blend has desktop-readable DNA: ${fixture}`, async ({ page }, testInfo) => { test.setTimeout(90_000); const file = path.join(root, "tests/files/web/generated", fixture); const before = desktop(file); await page.goto("/"); const saved = await page.evaluate(async (bytes) => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const client = new WebEngineClient(); try { await client.openBlend(Uint8Array.from(bytes).buffer); await client.applyCommand({ type: "setFrame", frame: 7 }); return Array.from(new Uint8Array(await client.saveBlend())); } catch (error) { throw new Error(JSON.stringify(error)); } finally { client.terminate(); } }, Array.from(fs.readFileSync(file))); const edited = testInfo.outputPath("edited.blend"); fs.writeFileSync(edited, Buffer.from(saved)); const after = desktop(edited); expect(after).toEqual({ ...before, frame: 7 }); await testInfo.attach("cross-abi", { body: JSON.stringify({ fixture, before, after }), contentType: "application/json" }); }); } test("invalid modifier edits leave Main serializable and unchanged", async ({ page }) => { await page.goto("/"); const fixture = fs.readFileSync(path.join(root, "tests/files/web/generated/WBV2-P4-001-MODIFIER.DECLARED_SLICES.blend")); const result = await page.evaluate(async (bytes) => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const client = new WebEngineClient(); try { await client.openBlend(Uint8Array.from(bytes).buffer); const before = await client.applyCommand({ type: "setFrame", frame: 7 }); const error = await client.applyCommand({ type: "addModifier", meshId: "missing:99999", modifierType: "PARTICLE_SYSTEM" }) .then(() => "unexpected success", (error) => error.code); const savedBytes = (await client.saveBlend()).byteLength; const after = await client.snapshot(); return { error, savedBytes, unchanged: JSON.stringify(before.snapshot) === JSON.stringify(after.snapshot) }; } catch (error) { throw new Error(JSON.stringify(error)); } finally { client.terminate(); } }, Array.from(fixture)); expect(result.error).toBe("INVALID_ARGUMENT"); expect(result.savedBytes).toBeGreaterThan(0); expect(result.unchanged).toBe(true); }); test("particle modifier creation and removal preserve linked systems across native recovery", async ({ page }, testInfo) => { test.setTimeout(60_000); const fixture = path.join(root, "tests/files/web/modifier_stack_scene.blend"); await page.goto("/"); const files = await page.evaluate(async (bytes) => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const client = new WebEngineClient(); const saved: Record = {}; const save = async (label: string) => { saved[label] = Array.from(new Uint8Array(await client.saveBlend())); }; try { const opened = await client.openBlend(Uint8Array.from(bytes).buffer); const meshId = opened.snapshot.nodes.find((node) => node.name === "BasicCube")!.dataId!; await save("before"); const added = await client.applyCommand({ type: "addModifier", meshId, modifierType: "PARTICLE_SYSTEM", name: "Web Particle" }); const modifier = added.snapshot.meshes.find((mesh) => mesh.id === meshId)!.modifierStack!.find((item) => item.type === "PARTICLE_SYSTEM")!; await save("added"); await client.applyCommand({ type: "undo" }); await save("undo"); await client.applyCommand({ type: "redo" }); await save("redo"); await client.openBlend(Uint8Array.from(saved.added).buffer); await save("reopened"); await client.applyCommand({ type: "removeModifier", meshId, modifierUuid: modifier.uuid }); await save("removed"); await client.applyCommand({ type: "undo" }); await save("restored"); return saved; } finally { client.terminate(); } }, Array.from(fs.readFileSync(fixture))); const states: Record> = {}; for (const [label, bytes] of Object.entries(files)) { const filename = testInfo.outputPath(`${label}.blend`); fs.writeFileSync(filename, Buffer.from(bytes)); states[label] = desktop(filename); } const systems = states.added.particles.find(([name]: [string]) => name === "BasicCube")[1]; expect(systems).toHaveLength(1); expect(systems[0]).toEqual(["Web Particle", expect.any(String), "EMITTER", expect.any(Number)]); expect(states.added).not.toEqual(states.before); for (const label of ["undo", "removed"]) expect(states[label]).toEqual(states.before); for (const label of ["redo", "reopened", "restored"]) expect(states[label]).toEqual(states.added); await testInfo.attach("particle-lifecycle", { body: JSON.stringify(states), contentType: "application/json" }); }); test("native server-result history restores packed assets and truncates redo on edit", async ({ page }) => { await page.goto("/"); const bytes = (name: string) => Array.from(fs.readFileSync(path.join(root, "tests/files/web/generated", name))); const result = await page.evaluate(async ({ source, replacement }) => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const client = new WebEngineClient(); try { const initial = await client.openBlend(Uint8Array.from(source).buffer); const image = initial.snapshot.images.find((image) => image.name === "WBV2P3005AppendImage"); if (!image) throw new Error("missing packed image fixture"); const original = await client.requestAsset(image.id); const digest = async (data: ArrayBuffer | undefined) => data ? Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), (n) => n.toString(16).padStart(2, "0")).join("") : null; const applied = await client.applyServerBlendResult(Uint8Array.from(replacement).buffer, initial.snapshot.revision); await client.applyCommand({ type: "undo" }); const restored = await client.requestAsset(image.id); const redone = await client.applyCommand({ type: "redo" }); await client.applyCommand({ type: "undo" }); const edited = await client.applyCommand({ type: "setFrame", frame: 8 }); const redoError = await client.applyCommand({ type: "redo" }).then(() => "unexpected success", (error) => error.message); const afterFailedRedo = await client.snapshot(); return { originalStatus: original.status, restoredStatus: restored.status, originalDigest: await digest(original.data), restoredDigest: await digest(restored.data), resultImages: applied.snapshot.images.map((i) => i.name), redoImages: redone.snapshot.images.map((i) => i.name), redoError, redoFailureUnchanged: JSON.stringify(edited.snapshot) === JSON.stringify(afterFailedRedo.snapshot) }; } catch (error) { throw new Error(JSON.stringify(error)); } finally { client.terminate(); } }, { source: bytes("WBV2P3005Library.blend"), replacement: bytes("WBV2-P24-001-OBJECT_OPERATIONS.MOVE_COLLECTION.blend") }); expect(result.originalStatus).toBe("packed"); expect(result.restoredStatus).toBe("packed"); expect(result.originalDigest).toBeTruthy(); expect(result.restoredDigest).toBe(result.originalDigest); expect(result.resultImages).not.toContain("WBV2P3005AppendImage"); expect(result.redoImages).not.toContain("WBV2P3005AppendImage"); expect(result.redoError).toContain("redo history is empty"); expect(result.redoFailureUnchanged).toBe(true); });