Files
workinf_Blender_Wasm/web/tests/e2e/rig-pose-authoring-native.spec.ts
mes123456 d1e6b63d54
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 feature-gap work, docs and tests
- update native web_engine sources and rebuilt wasm artifacts
- add task cards, status notes and tooling scripts
- add web e2e specs, tutorial UI sources and tutorial assets
- sync golden task manifests and audit records

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

104 lines
6.5 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";
const root = path.resolve(import.meta.dirname, "../../..");
const fixture = "tests/files/web/pose_constraint_scene.blend";
const reader = "web/tests/fixtures/rig-pose-authoring-main.py";
const units = { bones: "web-command:setArmatureBones", pose: "web-command:setPoseBoneTransform" };
const hash = (value: Buffer | string) => crypto.createHash("sha256").update(value).digest("hex");
for (const route of ["bones", "pose"] as const) {
test(`rig ${route} writes native Main with recovery and independent replay`, async ({ page }, testInfo) => {
test.setTimeout(90_000);
const receipts = process.env.WBV2_RECEIPT_DIR ?? testInfo.outputPath("receipts");
const directory = path.join(receipts, `rig-${route}-artifacts`);
fs.mkdirSync(directory, { recursive: true });
await page.goto("/?engine=manual");
const result = await page.evaluate(async input => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const clients: InstanceType<typeof WebEngineClient>[] = [];
const create = () => { const client = new WebEngineClient({ timeoutMs: 20_000 }); clients.push(client); return client; };
const files: Record<string, number[]> = {};
let stage = "open";
const save = async (owner: InstanceType<typeof WebEngineClient>, name: string) => {
stage = `save-${name}`;
const bytes = await owner.saveBlend();
files[name] = Array.from(new Uint8Array(bytes));
return bytes;
};
try {
const client = create();
const opened = await client.openBlend(Uint8Array.from(input.bytes).buffer);
const objectId = opened.snapshot.nodes.find(node => node.name === "PoseConstraintArmatureObject")?.id;
if (!objectId) throw new Error("Required fixture armature missing");
const bones = [
{ name: "Child", parentName: "Root", head: [1, 1, 2], tail: [1, 2, 3], roll: 0.25, connected: true },
{ name: "Root", parentName: null, head: [0, 0, 1], tail: [1, 1, 2], roll: 0.15, connected: false },
];
const command = (input.route === "bones"
? { type: "setArmatureBones", objectId, bones }
: { type: "setPoseBoneTransform", objectId, boneName: "Auxiliary", translation: [0.25, 0.5, 0.75], rotationEuler: [0.1, 0.2, 0.3], scale: [1.2, 1.1, 0.9] }) as WebEngineEditCommand;
const invalid = input.route === "bones"
? { type: "setArmatureBones", objectId, bones: [{ ...bones[0], parentName: "Missing" }, bones[1]] }
: { type: "setPoseBoneTransform", objectId, boneName: "Missing", translation: [1, 2, 3], rotationEuler: [0, 0, 0], scale: [1, 1, 1] };
await save(client, "before");
stage = "apply";
const applied = await client.applyCommand(command);
await save(client, "after");
let rejection = "NO_ERROR";
try { await client.callJson("applyCommand", { command: invalid }); }
catch (error) { rejection = String((error as { code?: string }).code ?? "UNKNOWN"); }
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");
client.terminate();
const reopen = create();
await reopen.openBlend(saved.slice(0));
await save(reopen, "reopened");
reopen.terminate();
const repeat = create();
await repeat.openBlend(Uint8Array.from(input.bytes).buffer);
await repeat.applyCommand(command);
await save(repeat, "second");
return { files, command, rejection, appliedRevision: applied.snapshot.revision, negativeRevision: negative.snapshot.revision };
} catch (error) {
throw new Error(JSON.stringify({ stage, error }), { cause: error });
} finally { for (const client of clients) client.terminate(); }
}, { route, bytes: Array.from(fs.readFileSync(path.join(root, fixture))) });
for (const [stage, bytes] of Object.entries(result.files)) fs.writeFileSync(path.join(directory, `${stage}.blend`), Buffer.from(bytes));
fs.writeFileSync(path.join(directory, "command.json"), JSON.stringify(result.command));
expect(result.rejection).not.toBe("NO_ERROR");
expect(result.rejection).not.toBe("UNKNOWN");
expect(result.negativeRevision).toBe(result.appliedRevision);
const native = path.resolve(root, process.env.BLENDER_BIN ?? "build_blender_native/bin/blender");
const log = fs.openSync(path.join(directory, "desktop.log"), "w");
try { execFileSync(native, ["--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/rig-pose-authoring-native.spec.ts", path.relative(root, native), "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 = `rig-${route}-${kind}`;
fs.writeFileSync(path.join(receipts, `${caseId}.json`), JSON.stringify({ status: "PASS", runtime: "CHROMIUM_WEBENGINE", caseId, kind, unitIds: [units[route]], observations: values, artifacts }, null, 2) + "\n");
}
});
}