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 fixturePath = path.join(root, "tests/files/web/m12_glb_main_v1/pbr.blend"); const fixture = fs.readFileSync(fixturePath); const fixtureSha256 = crypto.createHash("sha256").update(fixture).digest("hex"); const pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+W7j8WQAAAABJRU5ErkJggg=="; test("WBV2-P1113-000 writes Shader Graph through native Blender Main cmd json", async ({ page }) => { test.setTimeout(120_000); await page.goto("/?engine=manual"); const result = await page.evaluate(async ({ bytes, pngBase64: inputPngBase64 }) => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const digest = async (value: ArrayBuffer) => { const hash = await crypto.subtle.digest("SHA-256", value); return Array.from(new Uint8Array(hash), (item) => item.toString(16).padStart(2, "0")).join(""); }; const summarize = (snapshot: any, materialId: string) => { const material = snapshot.materials.find((candidate: any) => candidate.id === materialId); if (!material) throw new Error(`material not found: ${materialId}`); const nodeNames = new Map((material.nodes ?? []).map((node: any) => [node.id, node.name])); return { id: material.id, nodes: (material.nodes ?? []).map((node: any) => ({ type: node.type, name: node.name })).sort((a: any, b: any) => `${a.type}:${a.name}`.localeCompare(`${b.type}:${b.name}`)), links: (material.links ?? []).map((link: any) => ({ fromNode: nodeNames.get(link.fromNodeId) ?? link.fromNodeId, fromSocketId: link.fromSocketId, toNode: nodeNames.get(link.toNodeId) ?? link.toNodeId, toSocketId: link.toSocketId, })).sort((a: any, b: any) => JSON.stringify(a).localeCompare(JSON.stringify(b))), }; }; const client = new WebEngineClient({ timeoutMs: 60_000 }); let reopenedClient: WebEngineClient | null = null; try { const opened = await client.openBlend(Uint8Array.from(bytes).buffer); const material = opened.snapshot.materials[0]; if (!material) throw new Error("fixture has no material"); await client.callJson("applyCommand", { command: { type: "importImage", name: "WBV2 Shader Normal", mimeType: "image/png", width: 1, height: 1, base64: inputPngBase64 }, }); const importedSnapshot = await client.snapshot(); const importedImage = importedSnapshot.snapshot.images.find((image: any) => image.name === "WBV2 Shader Normal"); if (!importedImage) throw new Error("native Blender Main image import did not return an image"); const graph = { schemaVersion: 1, id: "shader:wbv2-p1113-native", materialId: material.id, outputNodeId: "output", nodes: [ { id: "rgb", type: "RGB", name: "WBV2 RGB", sockets: [{ id: "Color", name: "Color", direction: "OUTPUT", dataType: "COLOR", defaultValue: [0.17, 0.27, 0.37, 1] }], }, { id: "valueA", type: "VALUE", name: "WBV2 Value A", sockets: [{ id: "Value", name: "Value", direction: "OUTPUT", dataType: "VALUE", defaultValue: 0.2 }], }, { id: "valueB", type: "VALUE", name: "WBV2 Value B", sockets: [{ id: "Value", name: "Value", direction: "OUTPUT", dataType: "VALUE", defaultValue: 0.22 }], }, { id: "math", type: "MATH", name: "WBV2 Add", properties: { operation: "ADD" }, sockets: [ { id: "Value_001", name: "Value", direction: "INPUT", dataType: "VALUE", defaultValue: 0 }, { id: "Value_002", name: "Value_001", direction: "INPUT", dataType: "VALUE", defaultValue: 0 }, { id: "Value", name: "Value", direction: "OUTPUT", dataType: "VALUE" }, ], }, { id: "image", type: "IMAGE_TEXTURE", name: "WBV2 Image Texture", imageId: importedImage.id, sockets: [ { id: "Color", name: "Color", direction: "OUTPUT", dataType: "COLOR" }, ], }, { id: "normal", type: "NORMAL_MAP", name: "WBV2 Normal Map", sockets: [ { id: "Color", name: "Color", direction: "INPUT", dataType: "COLOR" }, { id: "Normal", name: "Normal", direction: "OUTPUT", dataType: "VECTOR" }, ], }, { id: "principled", type: "PRINCIPLED", name: "WBV2 Principled", sockets: [ { id: "Base Color", name: "Base Color", direction: "INPUT", dataType: "COLOR", defaultValue: [0.17, 0.27, 0.37, 1] }, { id: "Roughness", name: "Roughness", direction: "INPUT", dataType: "VALUE", defaultValue: 0.42 }, { id: "Normal", name: "Normal", direction: "INPUT", dataType: "VECTOR" }, { id: "BSDF", name: "BSDF", direction: "OUTPUT", dataType: "SHADER" }, ], }, { id: "output", type: "MATERIAL_OUTPUT", name: "WBV2 Output", sockets: [{ id: "Surface", name: "Surface", direction: "INPUT", dataType: "SHADER" }], }, ], links: [ { fromNodeId: "rgb", fromSocketId: "Color", toNodeId: "principled", toSocketId: "Base Color" }, { fromNodeId: "valueA", fromSocketId: "Value", toNodeId: "math", toSocketId: "Value_002" }, { fromNodeId: "valueB", fromSocketId: "Value", toNodeId: "math", toSocketId: "Value_001" }, { fromNodeId: "math", fromSocketId: "Value", toNodeId: "principled", toSocketId: "Roughness" }, { fromNodeId: "image", fromSocketId: "Color", toNodeId: "normal", toSocketId: "Color" }, { fromNodeId: "normal", fromSocketId: "Normal", toNodeId: "principled", toSocketId: "Normal" }, { fromNodeId: "principled", fromSocketId: "BSDF", toNodeId: "output", toSocketId: "Surface" }, ], }; const baseRevision = importedSnapshot.snapshot.revision; const applied = await client.callJson<{ status: string }>("applyCommand", { command: { type: "setShaderGraph", materialId: material.id, graph }, }); const after = await client.snapshot(); const afterGraph = summarize(after.snapshot, material.id); const undo = await client.callJson<{ status: string }>("undo"); const undoSnapshot = await client.snapshot(); const redo = await client.callJson<{ status: string }>("redo"); const redoSnapshot = await client.snapshot(); const malformed = await client.callJson("applyCommand", { command: { type: "setShaderGraph", materialId: material.id, graph: { ...graph, nodes: [{ ...graph.nodes[0], type: "UNSUPPORTED" }] } }, }).then(() => ({ code: "NO_ERROR" })).catch((error: any) => ({ code: error?.code, message: error?.message })); const afterMalformed = await client.snapshot(); const saved = await client.saveBlend(); client.terminate(); reopenedClient = new WebEngineClient({ timeoutMs: 60_000 }); const reopened = await reopenedClient.openBlend(saved.slice(0)); return { browser: "chromium", fixtureSha256: await digest(Uint8Array.from(bytes).buffer), materialId: material.id, baseRevision, appliedStatus: applied.result.status, afterGraph, undoStatus: undo.result.status, undoRevision: undoSnapshot.snapshot.revision, redoStatus: redo.result.status, redoRevision: redoSnapshot.snapshot.revision, malformed, malformedRevisionPreserved: afterMalformed.snapshot.revision === redoSnapshot.snapshot.revision, reopenedGraph: summarize(reopened.snapshot, material.id), reopenedRevision: reopened.snapshot.revision, }; } catch (error: any) { return { error: { code: error?.code, message: error?.message, detail: String(error) } }; } finally { client.terminate(); reopenedClient?.terminate(); } }, { bytes: Array.from(fixture), pngBase64 }); fs.mkdirSync(path.join(root, "tests/golden/WBV2-P1113-000"), { recursive: true }); fs.writeFileSync(path.join(root, "tests/golden/WBV2-P1113-000/shader-main-command-production.json"), `${JSON.stringify({ schemaVersion: 1, task: "WBV2-P1113-000", operation: "SHADER_GRAPH_MAIN_CMD_JSON_CHROMIUM", browser: "chromium", queueMutation: false, sourceCommand: "web_engine_blend_main_set_shader_graph", sourcePath: "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp", sourceNodes: { "node:ShaderNodeRGB": "material.nodes[type=RGB]", "node:ShaderNodeValue": "material.nodes[type=VALUE]", "node:ShaderNodeMath": "material.nodes[type=MATH]", "node:ShaderNodeTexImage": "material.nodes[type=IMAGE_TEXTURE]", "node:ShaderNodeNormalMap": "material.nodes[type=NORMAL_MAP]", "node:ShaderNodeBsdfPrincipled": "material.nodes[type=PRINCIPLED]", "node:ShaderNodeOutputMaterial": "material.nodes[type=OUTPUT]", }, jsonAbi: { command: "applyCommand", stateful: true, nativeOnly: true }, ...result, }, null, 2)}\n`); expect(result.browser).toBe("chromium"); expect(result.fixtureSha256).toBe(fixtureSha256); expect(result.appliedStatus).toBe("applied"); expect(result.afterGraph.nodes.map((node: any) => node.type)).toEqual(expect.arrayContaining(["RGB", "VALUE", "MATH", "PRINCIPLED", "OUTPUT"])); expect(result.afterGraph.nodes.map((node: any) => node.type)).toEqual(expect.arrayContaining(["RGB", "VALUE", "MATH", "IMAGE_TEXTURE", "NORMAL_MAP", "PRINCIPLED", "OUTPUT"])); expect(result.afterGraph.links).toHaveLength(7); expect(result.undoStatus).toBe("undo"); expect(result.redoStatus).toBe("redo"); expect(result.undoRevision).toBe(result.baseRevision + 2); expect(result.redoRevision).toBe(result.baseRevision + 3); expect(result.malformed.code).not.toBe("NO_ERROR"); expect(result.malformedRevisionPreserved).toBe(true); expect(result.reopenedGraph).toEqual(result.afterGraph); expect(result.reopenedRevision).toBe(1); });