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>
249 lines
16 KiB
TypeScript
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));
|
|
}
|
|
});
|
|
}
|