import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { expect, test } from "@playwright/test"; import type { WebEngineEditCommand } from "../../protocol/web-engine"; import { observeNative, NativeUIWorkflow } from "../fixtures/native-ui-workflow"; const root = path.resolve(import.meta.dirname, "../../.."); const fixturePath = "tests/files/web/modifier_stack_scene.blend"; const bytes = fs.readFileSync(path.join(root, fixturePath)); const routes = { add: "addModifier", remove: "removeModifier", move: "moveModifier", parameters: "setModifierParameters" } as const; type Route = keyof typeof routes; type MainState = Record }[]>; test("modifier stack panel commits only changed native fields", async ({ page }, testInfo) => { test.setTimeout(45_000); page.setDefaultTimeout(8_000); await observeNative(page); const workflow = new NativeUIWorkflow(page, testInfo.outputPath("panel")); await page.goto("/"); await workflow.load(path.join(root, fixturePath), "StackTarget"); const section = page.locator(".modifier-stack-authoring"); const bevel = section.locator(".modifier-row").filter({ hasText: "Stack Bevel" }); const count = await page.evaluate(() => (window as unknown as { __nativeWorkflowRequests: unknown[] }).__nativeWorkflowRequests.length); await bevel.getByLabel("width", { exact: true }).fill("0.35"); expect(await page.evaluate(() => (window as unknown as { __nativeWorkflowRequests: unknown[] }).__nativeWorkflowRequests.length)).toBe(count); await workflow.command(() => bevel.getByLabel("width", { exact: true }).press("Enter")); await expect(page.getByRole("treeitem").filter({ hasText: "StackTarget" })).toHaveAttribute("aria-selected", "true"); await workflow.command(async () => { await bevel.getByLabel("segments", { exact: true }).fill("4"); await bevel.getByLabel("segments", { exact: true }).press("Enter"); }); await bevel.screenshot({ path: testInfo.outputPath("modifier-fields-desktop.png") }); await page.setViewportSize({ width: 960, height: 844 }); await bevel.screenshot({ path: testInfo.outputPath("modifier-fields-compact.png") }); const geometry = await bevel.locator("input[type=number]").evaluateAll((inputs) => inputs.map((input) => { const rect = input.getBoundingClientRect(), parent = input.parentElement!.getBoundingClientRect(); return { width: rect.width, fits: rect.right <= parent.right + 1 && rect.left >= parent.left - 1 }; })); expect(geometry.every((item) => item.fits && item.width >= 60)).toBe(true); await page.setViewportSize({ width: 1280, height: 720 }); await workflow.click(page.getByRole("button", { name: "Move Stack Bevel up", exact: true })); await page.getByLabel("Modifier type", { exact: true }).fill("SOLIDIFY"); await workflow.click(section.getByRole("button", { name: "Add", exact: true })); await workflow.measured("remove-array", () => workflow.click(page.getByRole("button", { name: "Remove Stack Array", exact: true }))); await expect(section.locator(".modifier-row").filter({ hasText: "Stack Array" })).toHaveCount(0); await workflow.save("after"); const output = path.join(workflow.directory, "main.json"); const request = path.join(workflow.directory, "request.json"); fs.writeFileSync(request, JSON.stringify({ output, files: { after: path.join(workflow.directory, "after.blend") } })); execFileSync(path.join(root, "build_blender_native/bin/blender"), ["--background", "--factory-startup", "--python-exit-code", "1", "--python", path.join(root, "web/tests/fixtures/modifier-stack-main-snapshots.py"), "--", request], { timeout: 15_000 }); const native: { after: MainState } = JSON.parse(fs.readFileSync(output, "utf8")); expect(native.after.StackTarget.map((item) => item.type)).toEqual(["BEVEL", "SOLIDIFY"]); expect(native.after.StackTarget[0].values.width).toBe(0.35); expect(native.after.StackTarget[0].values.segments).toBe(4); expect(native.after.BasicCube.map((item) => item.type)).toEqual(["ARRAY", "BEVEL"]); }); const parameterTypes = ["BEVEL", "ARRAY", "MIRROR", "SOLIDIFY", "SUBSURF"] as const; const scenarios = (Object.keys(routes) as Route[]).flatMap((route) => (route === "parameters" ? parameterTypes : ["BEVEL"] as const).map((modifierType) => ({ route, modifierType }))); for (const { route, modifierType } of scenarios) { test(`modifier stack ${route} ${modifierType.toLowerCase()} preserves native Main and recovery`, async ({ page }, testInfo) => { test.setTimeout(45_000); const directory = process.env.WBV2_RECEIPT_DIR ?? testInfo.outputPath("receipts"); const scenario = route === "parameters" ? `parameters-${modifierType.toLowerCase()}` : route; fs.mkdirSync(directory, { recursive: true }); await page.goto("/"); const result = await page.evaluate(async (input) => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const clients: InstanceType[] = []; const files: Record = {}; const timings: { stage: string; seconds: number }[] = []; let stage = "open"; const measured = async (name: string, action: () => Promise) => { stage = name; const start = performance.now(); try { return await action(); } finally { timings.push({ stage: name, seconds: (performance.now() - start) / 1000 }); } }; const create = () => { const client = new WebEngineClient({ timeoutMs: 8_000 }); clients.push(client); return client; }; const save = async (client: InstanceType, label: string) => { const data = await measured(`save-${label}`, () => client.saveBlend()); files[label] = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result).split(",", 2)[1]); reader.onerror = () => reject(reader.error); reader.readAsDataURL(new Blob([data])); }); return data; }; try { const fixture = Uint8Array.from(atob(input.bytes), (char) => char.charCodeAt(0)).buffer; const client = create(); const opened = await measured("open", () => client.openBlend(fixture.slice(0))); const meshId = opened.snapshot.nodes.find((node) => node.name === "BasicCube")?.dataId; const mesh = opened.snapshot.meshes.find((mesh) => mesh.id === meshId); let bevel = mesh?.modifierStack?.find((modifier) => modifier.name === "Stack Bevel"); if (!meshId || !bevel) throw new Error("Modifier stack fixture incomplete"); if (input.route === "parameters") { let target = mesh?.modifierStack?.find((modifier) => modifier.type === input.modifierType); if (!target) { const added = await measured("prepare-modifier", () => client.applyCommand({ type: "addModifier", meshId, modifierType: input.modifierType, name: `Web ${input.modifierType}` })); target = added.snapshot.meshes.find((mesh) => mesh.id === meshId)?.modifierStack?.find((modifier) => modifier.type === input.modifierType); } if (!target) throw new Error("Native parameter target missing"); bevel = target; } const values = { BEVEL: { width: 0.25, segments: 3, profile: 0.7 }, ARRAY: { count: 3, use_relative_offset: false, relative_offset_displace: [1.5, 0.25, -0.5] }, MIRROR: { use_axis: [true, true, false], use_clip: true }, SOLIDIFY: { thickness: 0.25, offset: 0.5 }, SUBSURF: { levels: 2, render_levels: 3 }, }[input.modifierType]; const commands: Record = { add: { type: "addModifier", meshId, modifierType: "SOLIDIFY", name: "Web Solidify" }, remove: { type: "removeModifier", meshId, modifierUuid: bevel.uuid }, move: { type: "moveModifier", meshId, modifierUuid: bevel.uuid, direction: "UP" }, parameters: { type: "setModifierParameters", meshId, modifierUuid: bevel.uuid, parameters: values }, }; const poisonValues = { BEVEL: { segments: 7 }, ARRAY: { count: 9 }, MIRROR: { use_clip: false }, SOLIDIFY: { thickness: 0.75 }, SUBSURF: { levels: 4 } }[input.modifierType]; const invalid: Record = { add: { type: "addModifier", meshId, modifierType: "DOES_NOT_EXIST" }, remove: { type: "removeModifier", meshId, modifierUuid: "missing:99999" }, move: { type: "moveModifier", meshId, modifierUuid: "missing:99999", direction: "DOWN" }, parameters: { type: "setModifierParameters", meshId, modifierUuid: bevel.uuid, parameters: { ...poisonValues, z_invalid_property: true } }, }; await save(client, "before"); const applied = await measured("write", () => client.applyCommand(commands[input.route])); await save(client, "after"); const rejection = await measured("reject-native", async () => { try { await client.callJson("applyCommand", { command: invalid[input.route] }); return "NO_ERROR"; } catch (error) { return (error as { code?: string }).code ?? "UNKNOWN"; } }); const negative = await client.snapshot(); const secondaryRejections: string[] = []; if (input.route === "add" || input.route === "parameters") { const badValues = { BEVEL: [{ segments: 0 }, { width: -0.1 }, { segments: 2.5 }], ARRAY: [{ count: 0 }, { relative_offset_displace: [1, 2] }, { use_relative_offset: 1 }], MIRROR: [{ use_axis: [1, 1, 0] }, { use_clip: "true" }], SOLIDIFY: [{ offset: 2 }], SUBSURF: [{ levels: 12 }], }[input.modifierType]; const badCommands: WebEngineEditCommand[] = input.route === "add" ? [{ type: "addModifier", meshId: "missing:99999", modifierType: "PARTICLE_SYSTEM", name: "Invalid target" }] : badValues.map((parameters) => ({ type: "setModifierParameters", meshId, modifierUuid: bevel.uuid, parameters })); for (const command of badCommands) { secondaryRejections.push(await client.callJson("applyCommand", { command }).then(() => "NO_ERROR").catch((error: { code?: string }) => error.code ?? "UNKNOWN")); } } await save(client, "negative"); await measured("undo", () => client.applyCommand({ type: "undo" })); await save(client, "undo"); await measured("redo", () => client.applyCommand({ type: "redo" })); const saved = await save(client, "redo"); const reopened = create(); await measured("reopen", () => reopened.openBlend(saved.slice(0))); await save(reopened, "reopened"); const replay = create(); await measured("replay-open", () => replay.openBlend(Uint8Array.from(atob(files.before), (char) => char.charCodeAt(0)).buffer)); await measured("replay-write", () => replay.applyCommand(commands[input.route])); await save(replay, "replay"); return { files, timings, rejection, secondaryRejections, revisionBeforeNegative: applied.snapshot.revision, revisionAfterNegative: negative.snapshot.revision, parameters: bevel.parameters }; } catch (error) { const detail = error as { code?: string; message?: string }; return { error: { stage, code: detail.code, message: detail.message ?? String(error) }, files, timings }; } finally { for (const client of clients) client.terminate(); } }, { route, modifierType, bytes: bytes.toString("base64") }); const { files, ...diagnostic } = result; fs.writeFileSync(path.join(directory, `modifier-${scenario}-diagnostic.json`), JSON.stringify(diagnostic, null, 2)); expect(result.error).toBeUndefined(); if (result.error) throw new Error(result.error.message); const nativeFiles = Object.fromEntries(Object.entries(files).map(([label, data]) => { const file = path.join(directory, `modifier-${scenario}-${label}.blend`); fs.writeFileSync(file, Buffer.from(data, "base64")); return [label, file]; })); const output = path.join(directory, `modifier-${scenario}-main.json`); const request = path.join(directory, `modifier-${scenario}-request.json`); fs.writeFileSync(request, JSON.stringify({ files: nativeFiles, output })); const start = performance.now(); const log = execFileSync(path.join(root, "build_blender_native/bin/blender"), [ "--background", "--factory-startup", "--python-exit-code", "1", "--python", path.join(root, "web/tests/fixtures/modifier-stack-main-snapshots.py"), "--", request, ], { timeout: 15_000 }); result.timings.push({ stage: "desktop-readback", seconds: (performance.now() - start) / 1000 }); fs.writeFileSync(path.join(directory, `modifier-${scenario}-native.log`), log); const native: Record = JSON.parse(fs.readFileSync(output, "utf8")); expect(native.after).not.toEqual(native.before); const before = native.before.BasicCube, after = native.after.BasicCube; if (route === "add") expect(after.map((modifier) => modifier.type)).toEqual(["ARRAY", "BEVEL", "SOLIDIFY"]); if (route === "remove") expect(after.map((modifier) => modifier.type)).toEqual(["ARRAY"]); if (route === "move") expect(after.map((modifier) => modifier.type)).toEqual(["BEVEL", "ARRAY"]); if (route === "parameters") { const target = after.find((item) => item.type === modifierType)!; const expected = { BEVEL: { width: 0.25, segments: 3 }, ARRAY: { count: 3, use_relative_offset: false, relative_offset_displace: [1.5, 0.25, -0.5] }, MIRROR: { use_axis: [true, true, false], use_clip: true }, SOLIDIFY: { thickness: 0.25, offset: 0.5 }, SUBSURF: { levels: 2, render_levels: 3 } }[modifierType]; expect(target.values).toMatchObject(expected); expect(after.filter((item) => item.type !== modifierType)).toEqual(before.filter((item) => item.type !== modifierType)); } expect(result.secondaryRejections?.every((code) => code !== "NO_ERROR" && code !== "UNKNOWN")).toBe(true); expect(native.after.StackTarget).toEqual(native.before.StackTarget); expect(result.rejection).not.toBe("NO_ERROR"); expect(result.rejection).not.toBe("UNKNOWN"); expect(result.revisionAfterNegative).toBe(result.revisionBeforeNegative); expect(native.negative).toEqual(native.after); expect(native.undo).toEqual(native.before); expect(native.redo).toEqual(native.after); expect(native.reopened).toEqual(native.after); expect(native.replay).toEqual(native.after); const hash = (value: unknown) => crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); const observations = { positive: { before: hash(native.before), after: hash(native.after) }, negative: { before: hash(native.after), after: hash(native.negative) }, undo_redo: { before: hash(native.before), after: hash(native.after), undo: hash(native.undo), redo: hash(native.redo) }, save_reopen: { saved: hash(native.after), reopened: hash(native.reopened) }, determinism: { first: hash(native.after), second: hash(native.replay) }, }; const artifacts = [fixturePath, "build_web_blender6/bin/web_engine.wasm", "build_blender_native/bin/blender", path.relative(root, output), ...Object.values(nativeFiles).map((file) => path.relative(root, file)), ].map((file) => ({ path: file, sha256: crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex") })); for (const kind of Object.keys(observations) as (keyof typeof observations)[]) { const caseId = `modifier-${scenario}-${kind}`; fs.writeFileSync(path.join(directory, `${caseId}.json`), JSON.stringify({ status: "PASS", runtime: "CHROMIUM_WEBENGINE", caseId, kind, unitIds: [`web-command:${routes[route]}`], observations: observations[kind], artifacts, timings: result.timings, }, null, 2)); } }); }