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>
158 lines
8.9 KiB
TypeScript
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));
|
|
}
|
|
});
|