Files
workinf_Blender_Wasm/web/tests/e2e/nonmesh-advanced-ui-production.spec.ts
mes123456 f3e21c6b98
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender migration source, tools and docs
Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-10 11:18:01 +08:00

249 lines
16 KiB
TypeScript

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<string, string>): Record<string, Main> {
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<string, string> = { 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<typeof WebEngineClient>[] = [];
const files: Record<string, string> = {};
const timings: { stage: string; seconds: number }[] = [];
let stage = "open";
const measure = async <T>(name: string, action: () => Promise<T>) => {
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<typeof WebEngineClient>, label: string) => {
const data = await measure(`save-${label}`, () => client.saveBlend());
files[label] = await measure(`encode-${label}`, () => new Promise<string>((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<Route, WebEngineEditCommand> = {
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<Route, WebEngineEditCommand> = {
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<string, Main> = 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));
}
});
}