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) => { 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(); 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)); } });