Files
workinf_Blender_Wasm/web/tests/e2e/native-operator-bridge.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

85 lines
5.9 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";
const root = path.resolve(import.meta.dirname, "../../..");
const fixture = "tests/files/web/basic_scene.blend";
const reader = "web/tests/fixtures/operator-bridge-main.py";
const units = ["web-command:createPrimitive", "operator:WM_OT_open_mainfile"];
const hash = (value: string | Buffer) => crypto.createHash("sha256").update(value).digest("hex");
for (const primitive of ["cube", "plane"] as const) {
test(`operator ${primitive} discovery and invocation preserve Main history and reject UI context`, async ({ page }, testInfo) => {
test.setTimeout(90_000);
const receipts = process.env.WBV2_RECEIPT_DIR ?? testInfo.outputPath("receipts");
const directory = path.join(receipts, `operator-bridge-${primitive}-artifacts`);
fs.mkdirSync(directory, { recursive: true });
await page.goto("/");
const result = await page.evaluate(async ({ bytes, primitive }) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const clients: InstanceType<typeof WebEngineClient>[] = [];
const create = () => { const c = new WebEngineClient({ timeoutMs: 20_000 }); clients.push(c); return c; };
const blends: Record<string, number[]> = {};
const save = async (c: InstanceType<typeof WebEngineClient>, name: string) => {
const data = await c.saveBlend(); blends[name] = Array.from(new Uint8Array(data)); return data;
};
const invocation = { operator: `mesh.primitive_${primitive}_add`, properties: { name: "BridgePrimitive" } };
try {
const client = create();
await client.openBlend(Uint8Array.from(bytes).buffer);
const catalog = await client.listBlenderOperators();
const descriptor = catalog.operators.find(item => item.id === invocation.operator);
if (!descriptor) throw new Error("Cube operator missing in native catalog");
await save(client, "before");
const applied = await client.invokeBlenderOperator(invocation);
await save(client, "after");
const rejections: string[] = [];
for (const bad of [{ operator: invocation.operator, properties: { unexpected: true } }, { operator: "wm.open_mainfile", properties: { filepath: "/tmp/not-a-browser-file.blend" } }]) {
try { await client.callJson("invokeBlenderOperator", bad); rejections.push("NO_ERROR"); }
catch (error) { rejections.push(String((error as { message?: string }).message ?? error)); }
}
const negative = await client.snapshot();
await save(client, "negative");
await client.applyCommand({ type: "undo" }); await save(client, "undo");
await client.applyCommand({ type: "redo" }); const saved = await save(client, "redo");
const reopened = create(); await reopened.openBlend(saved.slice(0)); await save(reopened, "reopened");
const replay = create(); await replay.openBlend(Uint8Array.from(bytes).buffer);
await replay.invokeBlenderOperator(invocation); await save(replay, "second");
return { blends, descriptor, rejections, applied: applied.snapshot, negative: negative.snapshot };
} catch (error) { throw new Error(JSON.stringify(error), { cause: error }); }
finally { for (const client of clients) client.terminate(); }
}, { bytes: Array.from(fs.readFileSync(path.join(root, fixture))), primitive });
fs.writeFileSync(path.join(directory, "primitive.json"), JSON.stringify({ primitive }));
for (const [name, bytes] of Object.entries(result.blends)) fs.writeFileSync(path.join(directory, `${name}.blend`), Buffer.from(bytes));
fs.writeFileSync(path.join(directory, "browser.json"), JSON.stringify({ ...result, blends: undefined }, null, 2));
expect(result.descriptor.backgroundSafe).toBe(true);
expect(result.rejections[0]).toContain("OPERATOR_PROPERTIES_INVALID");
expect(result.rejections[1]).toContain("OPERATOR_UI_UNSUPPORTED");
expect(result.negative).toEqual(result.applied);
const binary = path.resolve(root, process.env.BLENDER_BIN ?? "build_blender_native/bin/blender");
const log = fs.openSync(path.join(directory, "desktop.log"), "w");
try { execFileSync(binary, ["--background", "--factory-startup", "--python-exit-code", "1", "--python", path.join(root, reader), "--", directory], { timeout: 30_000, stdio: ["ignore", log, log] }); }
finally { fs.closeSync(log); }
const states = JSON.parse(fs.readFileSync(path.join(directory, "native.json"), "utf8"));
expect(states.after).not.toEqual(states.before);
expect(states.after).toEqual(states.expected);
expect(states.undo).toEqual(states.before);
for (const stage of ["negative", "redo", "reopened", "second"]) expect(states[stage]).toEqual(states.after);
const digest = (stage: string) => hash(JSON.stringify(states[stage]));
const observations = {
positive: { before: digest("before"), after: digest("after") },
negative: { before: digest("after"), after: digest("negative") },
undo_redo: { before: digest("before"), after: digest("after"), undo: digest("undo"), redo: digest("redo") },
save_reopen: { saved: digest("redo"), reopened: digest("reopened") },
determinism: { first: digest("after"), second: digest("second") },
};
const artifacts = [fixture, reader, "web/tests/e2e/native-operator-bridge.spec.ts", path.relative(root, binary), "web/app/public/vendor/blender/web_engine.wasm", ...fs.readdirSync(directory).map(name => path.relative(root, path.join(directory, name)))].map(file => ({ path: file, sha256: hash(fs.readFileSync(path.join(root, file))) }));
for (const [kind, values] of Object.entries(observations)) {
const caseId = `native-operator-bridge-${primitive}-${kind}`;
fs.writeFileSync(path.join(receipts, `${caseId}.json`), JSON.stringify({ status: "PASS", runtime: "CHROMIUM_WEBENGINE", caseId, kind, unitIds: units, observations: values, artifacts }, null, 2) + "\n");
}
});
}