Files
workinf_Blender_Wasm/web/tests/e2e/nla-strip-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

158 lines
8.9 KiB
TypeScript

import { test, expect } from "@playwright/test";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
test("NLA Timeline moves native strips and preserves Main through failure and recovery", async ({ page, browser }, testInfo) => {
test.setTimeout(90000);
const root = path.resolve("..");
const receiptRoot = process.env.WBV2_RECEIPT_DIR ?? testInfo.outputPath("cases");
const artifactsRoot = path.join(receiptRoot, "nla-strip-artifacts");
const fixture = path.join(root, "tests/files/web/nla_time_mapping_scene.blend");
const native = process.env.BLENDER_BIN ?? path.join(root, "build_blender_native/bin/blender");
const compiled = path.join(root, "build_web_blender6/bin/web_engine.wasm");
const digest = (bytes: Buffer | string) => crypto.createHash("sha256").update(bytes).digest("hex");
fs.mkdirSync(artifactsRoot, { recursive: true });
const timings: { stage: string; seconds: number }[] = [];
const measured = async (stage: string, action: () => Promise<void>) => {
const start = performance.now();
try {
await action();
} finally {
timings.push({ stage, seconds: (performance.now() - start) / 1000 });
fs.writeFileSync(path.join(artifactsRoot, "stage-timings.json"), JSON.stringify(timings, null, 2));
if (!page.isClosed()) fs.writeFileSync(path.join(artifactsRoot, "last-stage-status.json"), JSON.stringify({ stage, responses: await page.evaluate(() => (window as any).__nlaResponses), status: await page.getByRole("status").allTextContents() }, null, 2));
}
};
const wasm = new Set<string>();
page.on("request", (request) => {
if (new URL(request.url()).pathname.endsWith(".wasm")) wasm.add(request.url());
});
const observeResponses = () => {
const records: { requestId: string; ok: boolean; errorCode?: string }[] = [];
(window as any).__nlaResponses = records;
window.Worker = new Proxy(window.Worker, {
construct(target, args) {
const worker = Reflect.construct(target, args) as Worker;
worker.addEventListener("message", ({ data }) => {
if (typeof data?.requestId === "string" && data.requestId.startsWith("web-engine-") && typeof data.ok === "boolean") {
records.push({ requestId: data.requestId, ok: data.ok, errorCode: data.error?.code });
}
});
return worker;
},
});
};
await page.addInitScript(observeResponses);
const startInput = page.getByTestId("nla-strip-frame-start");
const strips = page.getByTestId("nla-strip-select");
const load = async (file: string, frame: string) => {
const count = await page.evaluate(() => (window as any).__nlaResponses.length);
await page.getByTestId("blend-file-input").setInputFiles(file);
await expect.poll(() => page.evaluate(() => (window as any).__nlaResponses.length)).toBeGreaterThan(count);
await expect(page.getByTestId("open-progress")).not.toBeVisible();
await expect(startInput).toHaveValue(frame);
await expect(page.getByRole("button", { name: "保存项目", exact: true })).toBeEnabled();
};
const save = async (name: string) => {
const downloaded = page.waitForEvent("download", { timeout: 10000 });
await page.getByRole("button", { name: "保存项目", exact: true }).click();
const download = await downloaded;
await download.saveAs(path.join(artifactsRoot, `${name}.blend`));
};
const move = async (frame: number) => {
await startInput.fill(String(frame));
const count = await page.evaluate(() => (window as any).__nlaResponses.length);
await page.getByTestId("move-nla-strip").click();
await expect.poll(() => page.evaluate(() => (window as any).__nlaResponses.length)).toBeGreaterThan(count);
return page.evaluate(() => (window as any).__nlaResponses.at(-1));
};
await measured("load_fixture", async () => {
await page.goto("/");
await load(fixture, "20");
await expect(page.getByTestId("nla-track-select")).toHaveValue("nla-track:object:M10_NLA_TimeMapping:0");
await expect(strips).toHaveValue("M10 Scaled Clip");
await save("before");
});
await measured("move_strip", async () => {
expect((await move(10)).ok).toBe(true);
await expect(strips.locator("option:checked")).toContainText(/10.*30/);
await save("after");
});
await measured("reject_overlap", async () => {
expect(await move(50)).toMatchObject({ ok: false, errorCode: "NLA_INVALID_STACK" });
await expect(strips.locator("option:checked")).toContainText(/10.*30/);
await save("negative");
});
await measured("undo_redo", async () => {
await page.getByRole("button", { name: "撤销", exact: true }).click();
await expect(strips.locator("option:checked")).toContainText(/20.*40/);
await save("undo");
await page.getByRole("button", { name: "重做", exact: true }).click();
await expect(strips.locator("option:checked")).toContainText(/10.*30/);
await save("redo");
});
await measured("save_reopen", async () => {
await save("saved");
await load(path.join(artifactsRoot, "saved.blend"), "10");
await save("reopened");
});
await measured("determinism", async () => {
const context = await browser.newContext({ baseURL: new URL(page.url()).origin, acceptDownloads: true });
try {
const repeat = await context.newPage();
await repeat.addInitScript(observeResponses);
await repeat.goto("/");
await repeat.getByTestId("blend-file-input").setInputFiles(fixture);
await expect(repeat.getByTestId("nla-strip-frame-start")).toHaveValue("20");
await expect(repeat.getByTestId("open-progress")).not.toBeVisible();
await repeat.getByTestId("nla-strip-frame-start").fill("10");
await repeat.getByTestId("move-nla-strip").click();
await expect(repeat.getByTestId("nla-strip-select").locator("option:checked")).toContainText(/10.*30/);
const download = repeat.waitForEvent("download", { timeout: 10000 });
await repeat.getByRole("button", { name: "保存项目", exact: true }).click();
await (await download).saveAs(path.join(artifactsRoot, "second.blend"));
} finally {
await context.close();
}
});
await measured("native_main_readback", async () => {
execFileSync(native, ["--background", "--factory-startup", "--python-exit-code", "1", "--python", path.join(root, "web/tests/fixtures/nla-main-snapshots.py"), "--", artifactsRoot], { timeout: 30000, stdio: "pipe" });
});
const snapshots = JSON.parse(fs.readFileSync(path.join(artifactsRoot, "native-nla-snapshots.json"), "utf8"));
const expected = structuredClone(snapshots.before);
const target = expected.objects.find((object: { name: string }) => object.name === "M10_NLA_TimeMapping").tracks[0].strips[0];
expect({ start: target.start, end: target.end }).toEqual({ start: 20, end: 40 });
target.start = 10;
target.end = 30;
expect(snapshots.after).toEqual(expected);
expect(snapshots.negative).toEqual(snapshots.after);
expect(snapshots.undo).toEqual(snapshots.before);
expect(snapshots.redo).toEqual(snapshots.after);
expect(snapshots.saved).toEqual(snapshots.reopened);
expect(snapshots.second).toEqual(snapshots.after);
const hashes = Object.fromEntries(Object.entries(snapshots).map(([name, value]) => [name, digest(JSON.stringify(value))]));
const fetchedWasm = await Promise.all([...wasm].map(async (url) => {
const response = await page.request.get(url);
expect(response.ok()).toBe(true);
return { url, sha256: digest(await response.body()) };
}));
expect(fetchedWasm.some((entry) => entry.sha256 === digest(fs.readFileSync(compiled)))).toBe(true);
fs.writeFileSync(path.join(artifactsRoot, "runtime.json"), JSON.stringify({ status: "PASS", unit: "rna-struct:NlaStrip", timings, wasm: fetchedWasm, responses: await page.evaluate(() => (window as any).__nlaResponses) }, null, 2));
await page.screenshot({ path: path.join(artifactsRoot, "nla-strip-after.png") });
const paths = [native, compiled, fixture, ...fs.readdirSync(artifactsRoot).map((name) => path.join(artifactsRoot, name))];
const artifacts = paths.map((file) => ({ path: path.relative(root, file), sha256: digest(fs.readFileSync(file)) }));
const cases = [
{ kind: "positive", observations: { before: hashes.before, after: hashes.after } },
{ kind: "negative", observations: { before: hashes.after, after: hashes.negative } },
{ kind: "undo_redo", observations: { before: hashes.before, after: hashes.after, undo: hashes.undo, redo: hashes.redo } },
{ kind: "save_reopen", observations: { saved: hashes.saved, reopened: hashes.reopened } },
{ kind: "determinism", observations: { first: hashes.after, second: hashes.second } },
];
for (const entry of cases) {
const caseId = `nla-strip-${entry.kind}`;
fs.writeFileSync(path.join(receiptRoot, `${caseId}.json`), JSON.stringify({ status: "PASS", runtime: "CHROMIUM_WEBENGINE", caseId, ...entry, unitIds: ["rna-struct:NlaStrip"], artifacts }, null, 2));
}
});