import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { expect, test } from "@playwright/test"; const root = path.resolve(import.meta.dirname, "../../.."); const evidenceRoot = path.join(root, "tests/golden/WBV2-P12-003"); const materialFixture = fs.readFileSync(path.join(root, "tests/files/web/generated/WBV2-P3-001-MATERIAL.PBR_UV.blend")); const animationFixture = fs.readFileSync(path.join(root, "tests/files/web/generated/WBV2-P3-002-ANIMATION.TRS_ACTION.blend")); const digest = (bytes: Uint8Array): string => crypto.createHash("sha256").update(bytes).digest("hex"); test("WBV2-P12-003 executes material and animation mutations through compiled WASM Main", async ({ page }) => { test.setTimeout(240_000); await page.goto("/"); const result = await page.evaluate(async (input) => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const buffer = (values: number[]): ArrayBuffer => Uint8Array.from(values).buffer; const digestBytes = async (value: ArrayBuffer): Promise => { const hash = await crypto.subtle.digest("SHA-256", value); return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join(""); }; const shape = (error: any) => ({ code: error?.code, message: error?.message }); const materialClient = new WebEngineClient({ timeoutMs: 60_000 }); const materialReopenedClient = new WebEngineClient({ timeoutMs: 60_000 }); const animationClient = new WebEngineClient({ timeoutMs: 60_000 }); const animationReopenedClient = new WebEngineClient({ timeoutMs: 60_000 }); try { const materialOpened = await materialClient.openBlend(buffer(input.materialBytes)); const materialId = "material:WBV2P3001PrincipledMaterial"; const meshId = "mesh:WBV2P3001MaterialCubeMesh"; const sourceMaterial: any = materialOpened.snapshot.materials.find((candidate: any) => candidate.id === materialId); const sourceMesh: any = materialOpened.snapshot.meshes.find((candidate: any) => candidate.id === meshId); if (!sourceMaterial || !sourceMesh) throw new Error("material fixture is incomplete"); const createdUV = await materialClient.applyCommand({ type: "createUVMap", meshId, name: "WBV2P3001SecondaryUV", baseRevision: materialOpened.snapshot.revision } as any); const activeUV = await materialClient.applyCommand({ type: "setActiveUVMap", meshId, name: "WBV2P3001SecondaryUV", baseRevision: createdUV.snapshot.revision } as any); const unwrapped = await materialClient.applyCommand({ type: "unwrapUV", meshId, faceIndices: [0, 1, 2, 3, 4, 5], method: "CUBE", baseRevision: activeUV.snapshot.revision } as any); const editedMaterial = await materialClient.applyCommand({ type: "setMaterialPrincipled", materialId, baseColor: [0.2, 0.4, 0.6, 1], roughness: 0.31, metallic: 0.72, ior: 1.5, alpha: 1, baseRevision: unwrapped.snapshot.revision } as any); const invalidUV = await materialClient.applyCommand({ type: "setActiveUVMap", meshId, name: "missing-uv", baseRevision: editedMaterial.snapshot.revision } as any).then(() => ({ code: "NO_ERROR" })).catch(shape); const materialAfterFailure = await materialClient.snapshot(); const materialSaved = await materialClient.saveBlend(); const materialSavedSha256 = await digestBytes(materialSaved.slice(0)); const materialReopened = await materialReopenedClient.openBlend(materialSaved.slice(0)); const material = materialReopened.snapshot.materials.find((candidate: any) => candidate.id === materialId); const materialMesh = materialReopened.snapshot.meshes.find((candidate: any) => candidate.id === meshId); const animationOpened = await animationClient.openBlend(buffer(input.animationBytes)); const animationObject: any = animationOpened.snapshot.nodes.find((candidate: any) => candidate.id === "object:WBV2P3002AnimatedCube"); const animation: any = animationOpened.snapshot.animations.find((candidate: any) => candidate.targetId === animationObject?.id); if (!animationObject || !animation) throw new Error("animation fixture is incomplete"); const inserted = await animationClient.applyCommand({ type: "insertObjectKeyframe", objectId: animationObject.id, frame: 25, property: "LOCATION", interpolation: "BEZIER", baseRevision: animationOpened.snapshot.revision } as any); const interpolated = await animationClient.applyCommand({ type: "setFCurveInterpolation", animationId: animation.id, path: "location[0]", interpolation: "LINEAR", baseRevision: inserted.snapshot.revision } as any); const staleFrame = await animationClient.applyCommand({ type: "insertObjectKeyframe", objectId: animationObject.id, frame: 30, property: "ROTATION_EULER", interpolation: "BEZIER", baseRevision: animationOpened.snapshot.revision } as any).then(() => ({ code: "NO_ERROR" })).catch(shape); const animationAfterFailure = await animationClient.snapshot(); const animationSaved = await animationClient.saveBlend(); const animationSavedSha256 = await digestBytes(animationSaved.slice(0)); const animationReopened = await animationReopenedClient.openBlend(animationSaved.slice(0)); const reopenedAnimation: any = animationReopened.snapshot.animations.find((candidate: any) => candidate.id === animation.id); return { material: { sourceUnsupportedNode: sourceMaterial.nodes?.some((node: any) => node.type === "UNSUPPORTED") ?? false, baseRevision: materialOpened.snapshot.revision, finalRevision: editedMaterial.snapshot.revision, materialId, meshId, activeUVMap: materialMesh?.activeUVMap, baseColor: material?.baseColor, roughness: material?.roughness, metallic: material?.metallic, invalidUV, revisionAfterFailure: materialAfterFailure.snapshot.revision, savedByteLength: materialSaved.byteLength, sourceSha256: await digestBytes(buffer(input.materialBytes)), saveReopenExact: material?.baseColor?.join(",") === "0.20000000298023224,0.4000000059604645,0.6000000238418579,1" && materialMesh?.activeUVMap === "WBV2P3001SecondaryUV" }, animation: { baseRevision: animationOpened.snapshot.revision, insertedRevision: inserted.snapshot.revision, finalRevision: interpolated.snapshot.revision, animationId: animation.id, channelCount: animation.channels.length, locationFrames: interpolated.snapshot.animations.find((candidate: any) => candidate.id === animation.id)?.channels.find((channel: any) => channel.path === "location[0]")?.keyframes.map((keyframe: any) => keyframe.frame), interpolation: interpolated.snapshot.animations.find((candidate: any) => candidate.id === animation.id)?.channels.find((channel: any) => channel.path === "location[0]")?.keyframes.at(-1)?.interpolation, staleFrame, revisionAfterFailure: animationAfterFailure.snapshot.revision, savedByteLength: animationSaved.byteLength, sourceSha256: await digestBytes(buffer(input.animationBytes)), reopenedFrames: reopenedAnimation?.channels.find((channel: any) => channel.path === "location[0]")?.keyframes.map((keyframe: any) => keyframe.frame), reopenedInterpolation: reopenedAnimation?.channels.find((channel: any) => channel.path === "location[0]")?.keyframes.at(-1)?.interpolation, saveReopenExact: reopenedAnimation?.channels.find((channel: any) => channel.path === "location[0]")?.keyframes.at(-1)?.interpolation === "LINEAR" }, }; } finally { materialClient.terminate(); materialReopenedClient.terminate(); animationClient.terminate(); animationReopenedClient.terminate(); } }, { materialBytes: Array.from(materialFixture), animationBytes: Array.from(animationFixture) }); fs.mkdirSync(evidenceRoot, { recursive: true }); const output = process.env.WBV2_MATERIAL_ANIMATION_REPORT ?? "material-animation-production.json"; fs.writeFileSync(path.join(evidenceRoot, output), `${JSON.stringify({ schemaVersion: 1, task: "WBV2-P12-003", operation: "MATERIAL_ANIMATION_ENGINE_CHROMIUM", browser: "chromium", queueMutation: false, ...result }, null, 2)}\n`); expect(result.material.sourceUnsupportedNode).toBe(true); expect(result.material.finalRevision).toBe(result.material.baseRevision + 4); expect(result.material.activeUVMap).toBe("WBV2P3001SecondaryUV"); expect(result.material.baseColor[0]).toBeCloseTo(0.2, 5); expect(result.material.roughness).toBeCloseTo(0.31, 5); expect(result.material.metallic).toBeCloseTo(0.72, 5); expect(result.material.invalidUV.code).not.toBe("NO_ERROR"); expect(result.material.revisionAfterFailure).toBe(result.material.finalRevision); expect(result.material.saveReopenExact).toBe(true); expect(result.material.savedSha256).not.toBe(result.material.sourceSha256); expect(result.animation.insertedRevision).toBe(result.animation.baseRevision + 1); expect(result.animation.finalRevision).toBe(result.animation.baseRevision + 2); expect(result.animation.locationFrames).toEqual([1, 10, 20, 25]); expect(result.animation.interpolation).toBe("LINEAR"); expect(result.animation.staleFrame.code).not.toBe("NO_ERROR"); expect(result.animation.revisionAfterFailure).toBe(result.animation.finalRevision); expect(result.animation.reopenedFrames).toEqual([1, 10, 20, 25]); expect(result.animation.reopenedInterpolation).toBe("LINEAR"); expect(result.animation.saveReopenExact).toBe(true); expect(result.animation.savedSha256).not.toBe(result.animation.sourceSha256); });