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/nonmesh_scene.blend"; const bytes = fs.readFileSync(path.join(root, fixturePath)); const routes = { handle: "setCurveHandle", splines: "setCurveSplines", surface: "setSurfaceTopology", delete: "deleteNonMeshData" } as const; type Route = keyof typeof routes; type Spline = { type: string; cyclicU: boolean; cyclicV: boolean; u: number; v: number; points: number[][]; bezier: { co: number[]; left: number[]; right: number[] }[] }; type Main = { objects: { name: string; type: string; data: string | null }[]; curves: { name: string; type: string; splines: Spline[] }[] }; function readMain(directory: string, files: Record): Record { const output = path.join(directory, "main.json"); const request = path.join(directory, "request.json"); fs.writeFileSync(request, JSON.stringify({ files, output })); 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/nonmesh-advanced-main-snapshots.py"), "--", request, ], { timeout: 15_000 }); fs.writeFileSync(path.join(directory, "native.log"), log); return JSON.parse(fs.readFileSync(output, "utf8")); } for (const panelType of ["curve", "surface", "delete"] as const) { test(`nonmesh panel ${panelType} edits the selected data`, 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("/"); const object = panelType === "curve" ? "WebCurveObject" : panelType === "surface" ? "WebSurfaceObject" : "WebFontObject"; await workflow.load(path.join(root, fixturePath), object); await workflow.save("before"); const panel = page.getByTestId(panelType === "curve" ? "curve-advanced-workbench" : panelType === "surface" ? "surface-topology-workbench" : "nonmesh-advanced-workbench"); const files: Record = { before: path.join(workflow.directory, "before.blend") }; if (panelType === "curve") { await page.getByLabel("Curve spline", { exact: true }).selectOption("1"); await workflow.measured("nudge-handle", () => workflow.click(panel.getByRole("button", { name: "Nudge Handle X", exact: true }))); await expect(panel).toBeVisible(); await workflow.save("handle"); files.handle = path.join(workflow.directory, "handle.blend"); for (const [name, label] of [["poly", "Set Selected Poly"], ["nurbs", "Set Selected NURBS"], ["bezier", "Set Selected Bezier"]]) { await workflow.measured(`convert-${name}`, () => workflow.click(panel.getByRole("button", { name: label, exact: true }))); await expect(panel).toBeVisible(); await workflow.save(name); files[name] = path.join(workflow.directory, `${name}.blend`); if (name === "nurbs") { await workflow.measured("apply-nurbs", () => workflow.click(panel.getByRole("button", { name: "Apply Current Splines", exact: true }))); await workflow.save("nurbs-applied"); files["nurbs-applied"] = path.join(workflow.directory, "nurbs-applied.blend"); } } await workflow.measured("apply-current", () => workflow.click(panel.getByRole("button", { name: "Apply Current Splines", exact: true }))); } else if (panelType === "surface") { for (const label of ["Toggle U Cyclic", "Toggle V Cyclic", "Apply Current Topology"]) { await workflow.measured(label, () => workflow.click(panel.getByRole("button", { name: label, exact: true }))); await expect(panel).toBeVisible(); } } else { await workflow.measured("delete", () => workflow.click(panel.getByRole("button", { name: "Delete Active Data", exact: true }))); await expect(page.getByRole("treeitem").filter({ hasText: "WebFontObject" })).toHaveCount(0); } await workflow.save("after"); files.after = path.join(workflow.directory, "after.blend"); const states = readMain(workflow.directory, files); if (panelType === "curve") { const curve = (state: string) => states[state].curves.find((entry) => entry.name === "WebCurveData")!; expect(curve("handle").splines[1].bezier[0].left[0]).toBeGreaterThan(curve("before").splines[1].bezier[0].left[0]); for (const [state, type] of [["poly", "POLY"], ["nurbs", "NURBS"], ["bezier", "BEZIER"]]) { expect(curve(state).splines.map((entry) => entry.type)).toEqual(["POLY", type]); expect(curve(state).splines[0]).toEqual(curve("before").splines[0]); } expect(curve("after")).toEqual(curve("bezier")); expect(curve("nurbs-applied")).toEqual(curve("nurbs")); } else if (panelType === "surface") { const before = states.before.curves.find((entry) => entry.name === "WebSurfaceData")!.splines[0]; const after = states.after.curves.find((entry) => entry.name === "WebSurfaceData")!.splines[0]; expect(after.cyclicU).toBe(!before.cyclicU); expect(after.cyclicV).toBe(!before.cyclicV); expect(after.points).toEqual(before.points); } else { expect(states.after.curves.some((entry) => entry.name === "WebFontData")).toBe(false); expect(states.after.objects.some((entry) => entry.name === "WebFontObject")).toBe(false); } }); } for (const route of Object.keys(routes) as Route[]) { test(`nonmesh ${route} writes native Main and preserves recovery`, async ({ page }, testInfo) => { test.setTimeout(45_000); const directory = process.env.WBV2_RECEIPT_DIR ?? testInfo.outputPath("receipts"); 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 measure = async (name: string, action: () => Promise) => { stage = name; const started = performance.now(); try { return await action(); } finally { timings.push({ stage: name, seconds: (performance.now() - started) / 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 measure(`save-${label}`, () => client.saveBlend()); files[label] = await measure(`encode-${label}`, () => 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 measure("open", () => client.openBlend(fixture.slice(0))); await measure("create-empty-context", () => client.applyCommand({ type: "createObjectData", objectType: "EMPTY", name: "AAA_EmptyContext" })); const curve = opened.snapshot.nonMeshData?.find((item) => item.name === "WebCurveData"); const surface = opened.snapshot.nonMeshData?.find((item) => item.name === "WebSurfaceData"); const font = opened.snapshot.nonMeshData?.find((item) => item.name === "WebFontData"); if (!curve || !surface || !font || !surface.splineDimensions) throw new Error("Nonmesh fixture incomplete"); const commands: Record = { handle: { type: "setCurveHandle", dataId: curve.id, pointIndex: 4, side: "LEFT", position: [0.25, -0.5, 0.75] }, splines: { type: "setCurveSplines", dataId: curve.id, splineTypes: ["POLY", "NURBS"], splineOffsets: [0, 4, 7], ordersU: [0, 3], cyclicU: [true, false], pointWeights: [1, 1, 1, 1, 1, 1.25, 1], controlPoints: [0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 1, 1, 0, 2, 2, 0, 1], handleTypes: [], handlePoints: [] }, surface: { type: "setSurfaceTopology", dataId: surface.id, splineDimensions: surface.splineDimensions, cyclicU: surface.splineDimensions.map(() => true), cyclicV: surface.splineDimensions.map(() => false), controlPoints: Array.from({ length: 16 }, (_, index) => [index % 4, Math.floor(index / 4), index === 5 ? 0.5 : 0]).flat(), pointWeights: Array(16).fill(1) }, delete: { type: "deleteNonMeshData", dataId: font.id }, }; const invalid: Record = { handle: { type: "setCurveHandle", dataId: curve.id, pointIndex: 9999, side: "LEFT", position: [0, 0, 0] }, splines: { type: "setCurveSplines", dataId: curve.id, splineTypes: ["NURBS"], splineOffsets: [0, 3], ordersU: [4], cyclicU: [false], pointWeights: [1, 1, 1], controlPoints: [0, 0, 0, 1, 0, 0, 1, 1, 0], handleTypes: [], handlePoints: [] }, surface: { type: "setSurfaceTopology", dataId: surface.id, splineDimensions: [{ u: 1, v: 16, orderU: 4, orderV: 4 }], cyclicU: [false], cyclicV: [false], controlPoints: Array(48).fill(0), pointWeights: Array(16).fill(1) }, delete: { type: "deleteNonMeshData", dataId: "curve:missing" }, }; await save(client, "before"); const applied = await measure("write", () => client.applyCommand(commands[input.route])); await save(client, "after"); const rejection = await measure("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 afterNegative = await client.snapshot(); await save(client, "negative"); await measure("undo", () => client.applyCommand({ type: "undo" })); await save(client, "undo"); await measure("redo", () => client.applyCommand({ type: "redo" })); const saved = await save(client, "redo"); const reopened = create(); await measure("reopen", () => reopened.openBlend(saved.slice(0))); await save(reopened, "reopened"); const replay = create(); await measure("replay-open", () => replay.openBlend(fixture.slice(0))); await replay.applyCommand({ type: "createObjectData", objectType: "EMPTY", name: "AAA_EmptyContext" }); await measure("replay-write", () => replay.applyCommand(commands[input.route])); await save(replay, "replay"); return { files, timings, rejection, revisionBeforeNegative: applied.snapshot.revision, revisionAfterNegative: afterNegative.snapshot.revision }; } 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, bytes: bytes.toString("base64") }); const { files, ...diagnostic } = result; fs.writeFileSync(path.join(directory, `nonmesh-${route}-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, `nonmesh-${route}-${label}.blend`); fs.writeFileSync(file, Buffer.from(data, "base64")); return [label, file]; })); const output = path.join(directory, `nonmesh-${route}-main.json`); const request = path.join(directory, `nonmesh-${route}-request.json`); fs.writeFileSync(request, JSON.stringify({ files: nativeFiles, output })); const started = 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/nonmesh-advanced-main-snapshots.py"), "--", request, ], { timeout: 15_000 }); result.timings.push({ stage: "desktop-readback", seconds: (performance.now() - started) / 1000 }); fs.writeFileSync(path.join(directory, `nonmesh-${route}-native.log`), log); const native: Record = JSON.parse(fs.readFileSync(output, "utf8")); expect(native.after).not.toEqual(native.before); if (route === "handle") expect(native.after.curves.find((item) => item.name === "WebCurveData")?.splines[1].bezier[0].left).toEqual([0.25, -0.5, 0.75]); if (route === "splines") { const splines = native.after.curves.find((item) => item.name === "WebCurveData")!.splines; expect(splines.map((item) => item.type)).toEqual(["POLY", "NURBS"]); expect(splines[1].points[1]).toEqual([1, 0, 2, 1.25]); } if (route === "surface") { const surface = native.after.curves.find((item) => item.name === "WebSurfaceData")!.splines[0]; expect(surface.cyclicU).toBe(true); expect(surface.points[5]).toEqual([1, 1, 0.5, 1]); } if (route === "delete") { expect(native.after.curves.some((item) => item.name === "WebFontData")).toBe(false); expect(native.after.objects.some((item) => item.name === "WebFontObject")).toBe(false); } 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 = `nonmesh-${route}-${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)); } }); }