Files
workinf_Blender_Wasm/web/tests/e2e/modifier-stack-ui-production.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

242 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/modifier_stack_scene.blend";
const bytes = fs.readFileSync(path.join(root, fixturePath));
const routes = { add: "addModifier", remove: "removeModifier", move: "moveModifier", parameters: "setModifierParameters" } as const;
type Route = keyof typeof routes;
type MainState = Record<string, { name: string; type: string; viewport: boolean; render: boolean; values: Record<string, unknown> }[]>;
test("modifier stack panel commits only changed native fields", 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("/");
await workflow.load(path.join(root, fixturePath), "StackTarget");
const section = page.locator("fieldset").filter({ hasText: "StackTarget: Modifiers" }).first();
const bevel = section.locator('[data-modifier-row][data-modifier-name="Stack Bevel"]');
const count = await page.evaluate(() => (window as unknown as { __nativeWorkflowRequests: unknown[] }).__nativeWorkflowRequests.length);
await bevel.getByLabel("Amount", { exact: true }).fill("0.35");
expect(await page.evaluate(() => (window as unknown as { __nativeWorkflowRequests: unknown[] }).__nativeWorkflowRequests.length)).toBe(count);
await workflow.command(() => bevel.getByLabel("Amount", { exact: true }).press("Enter"));
await expect(page.getByRole("treeitem").filter({ hasText: "StackTarget" })).toHaveAttribute("aria-selected", "true");
await workflow.command(async () => { await bevel.getByLabel("Segments", { exact: true }).fill("4"); await bevel.getByLabel("Segments", { exact: true }).press("Enter"); });
await bevel.screenshot({ path: testInfo.outputPath("modifier-fields-desktop.png") });
await page.setViewportSize({ width: 960, height: 844 });
await bevel.screenshot({ path: testInfo.outputPath("modifier-fields-compact.png") });
const geometry = await bevel.locator("input[type=number]").evaluateAll((inputs) => inputs.map((input) => {
const rect = input.getBoundingClientRect(), parent = input.parentElement!.getBoundingClientRect();
return { width: rect.width, fits: rect.right <= parent.right + 1 && rect.left >= parent.left - 1 };
}));
expect(geometry.every((item) => item.fits && item.width >= 60)).toBe(true);
await page.setViewportSize({ width: 1280, height: 720 });
await workflow.click(page.getByRole("button", { name: "Move Stack Bevel up", exact: true }));
await page.getByLabel("Properties modifier type", { exact: true }).selectOption("Solidify");
await workflow.click(section.getByRole("button", { name: "Add Modifier", exact: true }));
await workflow.measured("remove-array", () => workflow.click(section.locator('[data-modifier-row][data-modifier-name="Stack Array"]').getByRole("button", { name: "Remove", exact: true })));
await expect(section.locator('[data-modifier-row][data-modifier-name="Stack Array"]')).toHaveCount(0);
await workflow.save("after");
const output = path.join(workflow.directory, "main.json");
const request = path.join(workflow.directory, "request.json");
fs.writeFileSync(request, JSON.stringify({ output, files: { after: path.join(workflow.directory, "after.blend") } }));
execFileSync(path.join(root, "build_blender_native/bin/blender"), ["--background", "--factory-startup", "--python-exit-code", "1", "--python",
path.join(root, "web/tests/fixtures/modifier-stack-main-snapshots.py"), "--", request], { timeout: 15_000 });
const native: { after: MainState } = JSON.parse(fs.readFileSync(output, "utf8"));
expect(native.after.StackTarget.map((item) => item.type)).toEqual(["BEVEL", "SOLIDIFY"]);
expect(native.after.StackTarget[0].values.width).toBe(0.35);
expect(native.after.StackTarget[0].values.segments).toBe(4);
expect(native.after.BasicCube.map((item) => item.type)).toEqual(["ARRAY", "BEVEL"]);
});
const parameterTypes = ["BEVEL", "ARRAY", "MIRROR", "SOLIDIFY", "SUBSURF"] as const;
const scenarios = (Object.keys(routes) as Route[]).flatMap((route) => (route === "parameters" ? parameterTypes : ["BEVEL"] as const).map((modifierType) => ({ route, modifierType })));
for (const { route, modifierType } of scenarios) {
test(`modifier stack ${route} ${modifierType.toLowerCase()} preserves native Main and recovery`, async ({ page }, testInfo) => {
test.setTimeout(45_000);
const directory = process.env.WBV2_RECEIPT_DIR ?? testInfo.outputPath("receipts");
const scenario = route === "parameters" ? `parameters-${modifierType.toLowerCase()}` : route;
fs.mkdirSync(directory, { recursive: true });
// These scenarios own the only engine client; the app page would already
// hold the single-writer lock. The blank manual host keeps the contract.
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 files: Record<string, string> = {};
const timings: { stage: string; seconds: number }[] = [];
let stage = "open";
const measured = async <T>(name: string, action: () => Promise<T>) => {
stage = name;
const start = performance.now();
try { return await action(); }
finally { timings.push({ stage: name, seconds: (performance.now() - start) / 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 measured(`save-${label}`, () => client.saveBlend());
files[label] = await 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 measured("open", () => client.openBlend(fixture.slice(0)));
const meshId = opened.snapshot.nodes.find((node) => node.name === "BasicCube")?.dataId;
const mesh = opened.snapshot.meshes.find((mesh) => mesh.id === meshId);
let bevel = mesh?.modifierStack?.find((modifier) => modifier.name === "Stack Bevel");
if (!meshId || !bevel) throw new Error("Modifier stack fixture incomplete");
if (input.route === "parameters") {
let target = mesh?.modifierStack?.find((modifier) => modifier.type === input.modifierType);
if (!target) {
const added = await measured("prepare-modifier", () => client.applyCommand({ type: "addModifier", meshId, modifierType: input.modifierType, name: `Web ${input.modifierType}` }));
target = added.snapshot.meshes.find((mesh) => mesh.id === meshId)?.modifierStack?.find((modifier) => modifier.type === input.modifierType);
}
if (!target) throw new Error("Native parameter target missing");
bevel = target;
}
const values = {
BEVEL: { width: 0.25, segments: 3, profile: 0.7 },
ARRAY: { count: 3, use_relative_offset: false, relative_offset_displace: [1.5, 0.25, -0.5] },
MIRROR: { use_axis: [true, true, false], use_clip: true },
SOLIDIFY: { thickness: 0.25, offset: 0.5 },
SUBSURF: { levels: 2, render_levels: 3 },
}[input.modifierType];
const commands: Record<Route, WebEngineEditCommand> = {
add: { type: "addModifier", meshId, modifierType: "SOLIDIFY", name: "Web Solidify" },
remove: { type: "removeModifier", meshId, modifierUuid: bevel.uuid },
move: { type: "moveModifier", meshId, modifierUuid: bevel.uuid, direction: "UP" },
parameters: { type: "setModifierParameters", meshId, modifierUuid: bevel.uuid,
parameters: values },
};
const poisonValues = { BEVEL: { segments: 7 }, ARRAY: { count: 9 }, MIRROR: { use_clip: false },
SOLIDIFY: { thickness: 0.75 }, SUBSURF: { levels: 4 } }[input.modifierType];
const invalid: Record<Route, WebEngineEditCommand> = {
add: { type: "addModifier", meshId, modifierType: "DOES_NOT_EXIST" },
remove: { type: "removeModifier", meshId, modifierUuid: "missing:99999" },
move: { type: "moveModifier", meshId, modifierUuid: "missing:99999", direction: "DOWN" },
parameters: { type: "setModifierParameters", meshId, modifierUuid: bevel.uuid,
parameters: { ...poisonValues, z_invalid_property: true } },
};
await save(client, "before");
const applied = await measured("write", () => client.applyCommand(commands[input.route]));
await save(client, "after");
const rejection = await measured("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 negative = await client.snapshot();
const secondaryRejections: string[] = [];
if (input.route === "add" || input.route === "parameters") {
const badValues = {
BEVEL: [{ segments: 0 }, { width: -0.1 }, { segments: 2.5 }],
ARRAY: [{ count: 0 }, { relative_offset_displace: [1, 2] }, { use_relative_offset: 1 }],
MIRROR: [{ use_axis: [1, 1, 0] }, { use_clip: "true" }],
SOLIDIFY: [{ offset: 200000 }], SUBSURF: [{ levels: 12 }],
}[input.modifierType];
const badCommands: WebEngineEditCommand[] = input.route === "add"
? [{ type: "addModifier", meshId: "missing:99999", modifierType: "PARTICLE_SYSTEM", name: "Invalid target" }]
: badValues.map((parameters) => ({ type: "setModifierParameters", meshId, modifierUuid: bevel.uuid, parameters }));
for (const command of badCommands) {
secondaryRejections.push(await client.callJson("applyCommand", { command }).then(() => "NO_ERROR").catch((error: { code?: string }) => error.code ?? "UNKNOWN"));
}
}
await save(client, "negative");
await measured("undo", () => client.applyCommand({ type: "undo" }));
await save(client, "undo");
await measured("redo", () => client.applyCommand({ type: "redo" }));
const saved = await save(client, "redo");
client.terminate();
const reopened = create();
await measured("reopen", () => reopened.openBlend(saved.slice(0)));
await save(reopened, "reopened");
reopened.terminate();
const replay = create();
await measured("replay-open", () => replay.openBlend(Uint8Array.from(atob(files.before), (char) => char.charCodeAt(0)).buffer));
await measured("replay-write", () => replay.applyCommand(commands[input.route]));
await save(replay, "replay");
return { files, timings, rejection, secondaryRejections, revisionBeforeNegative: applied.snapshot.revision,
revisionAfterNegative: negative.snapshot.revision, parameters: bevel.parameters };
} 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, modifierType, bytes: bytes.toString("base64") });
const { files, ...diagnostic } = result;
fs.writeFileSync(path.join(directory, `modifier-${scenario}-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, `modifier-${scenario}-${label}.blend`);
fs.writeFileSync(file, Buffer.from(data, "base64"));
return [label, file];
}));
const output = path.join(directory, `modifier-${scenario}-main.json`);
const request = path.join(directory, `modifier-${scenario}-request.json`);
fs.writeFileSync(request, JSON.stringify({ files: nativeFiles, output }));
const start = 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/modifier-stack-main-snapshots.py"), "--", request,
], { timeout: 15_000 });
result.timings.push({ stage: "desktop-readback", seconds: (performance.now() - start) / 1000 });
fs.writeFileSync(path.join(directory, `modifier-${scenario}-native.log`), log);
const native: Record<string, MainState> = JSON.parse(fs.readFileSync(output, "utf8"));
expect(native.after).not.toEqual(native.before);
const before = native.before.BasicCube, after = native.after.BasicCube;
if (route === "add") expect(after.map((modifier) => modifier.type)).toEqual(["ARRAY", "BEVEL", "SOLIDIFY"]);
if (route === "remove") expect(after.map((modifier) => modifier.type)).toEqual(["ARRAY"]);
if (route === "move") expect(after.map((modifier) => modifier.type)).toEqual(["BEVEL", "ARRAY"]);
if (route === "parameters") {
const target = after.find((item) => item.type === modifierType)!;
const expected = { BEVEL: { width: 0.25, segments: 3 }, ARRAY: { count: 3, use_relative_offset: false, relative_offset_displace: [1.5, 0.25, -0.5] },
MIRROR: { use_axis: [true, true, false], use_clip: true }, SOLIDIFY: { thickness: 0.25, offset: 0.5 }, SUBSURF: { levels: 2, render_levels: 3 } }[modifierType];
expect(target.values).toMatchObject(expected);
expect(after.filter((item) => item.type !== modifierType)).toEqual(before.filter((item) => item.type !== modifierType));
}
expect(result.secondaryRejections?.every((code) => code !== "NO_ERROR" && code !== "UNKNOWN")).toBe(true);
expect(native.after.StackTarget).toEqual(native.before.StackTarget);
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 = `modifier-${scenario}-${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));
}
});
}