import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { expect, test, type Page } from "@playwright/test"; import { NativeUIWorkflow, exposeWebTools, observeNative } from "../fixtures/native-ui-workflow"; const root = path.resolve(import.meta.dirname, "../../.."); const fixture = path.join(root, "tests/files/web/nonmesh_scene.blend"); const fixtureSha256 = crypto.createHash("sha256").update(fs.readFileSync(fixture)).digest("hex"); const kinds = ["positive", "negative", "undo_redo", "save_reopen", "determinism"] as const; const digest = (value: unknown) => crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); const call = (page: Page, command: unknown) => page.evaluate(command => new Promise((resolve, reject) => { const worker = (window as unknown as { __nativeWorkflowEngine: Worker }).__nativeWorkflowEngine; const requestId = `metaball-${crypto.randomUUID()}`; const receive = (event: MessageEvent) => { if (event.data?.requestId !== requestId || event.data?.kind !== "result") return; worker.removeEventListener("message", receive); if (event.data.ok) resolve(event.data.result); else reject(event.data.error); }; worker.addEventListener("message", receive); worker.postMessage({ requestId, command }); }), command); const readMetaball = async (page: Page) => { const read = await call(page, { type: "snapshot" }); const data = (read.snapshot.nonMeshData ?? []).find((entry: any) => entry.type === "METABALL" && entry.elements?.length); if (!data) throw new Error("fixture has no Metaball elements"); return { dataId: data.id as string, state: { revision: read.snapshot.revision, elements: data.elements.map((element: any) => ({ type: element.type, position: [...element.position], radius: element.radius, scale: [...element.scale] })) }, }; }; test("WBV2-P1137-001 preserves Metaball element edits through Main recovery and reopen", async ({ page }, testInfo) => { test.setTimeout(180_000); page.setDefaultTimeout(15_000); const receipts = process.env.WBV2_RECEIPT_DIR ?? testInfo.outputPath("cases"); const directory = path.join(receipts, "metaball-artifacts"); fs.mkdirSync(directory, { recursive: true }); const flow = new NativeUIWorkflow(page, directory); const load = async (file: string) => flow.command(() => page.getByTestId("blend-file-input").setInputFiles(file)); await observeNative(page); await page.goto("/"); await load(fixture); let result: any = { error: { fatal: true } }; try { const opened = await readMetaball(page); const before = opened.state; const elements = before.elements.map((element: any, index: number) => index === 0 ? { ...element, position: [element.position[0] + 0.125, element.position[1], element.position[2]], radius: element.radius + 0.05 } : { ...element, position: [...element.position], scale: [...element.scale] }); const edited = await call(page, { type: "applyCommand", payload: { type: "setMetaballElements", dataId: opened.dataId, elements } }); const afterEdit = { revision: edited.snapshot.revision, elements: edited.snapshot.nonMeshData.find((entry: any) => entry.id === opened.dataId).elements.map((element: any) => ({ type: element.type, position: [...element.position], radius: element.radius, scale: [...element.scale] })) }; const undo = await call(page, { type: "applyCommand", payload: { type: "undo" } }); const undoData = undo.snapshot.nonMeshData.find((entry: any) => entry.id === opened.dataId); const afterUndo = { revision: undo.snapshot.revision, elements: undoData.elements.map((element: any) => ({ type: element.type, position: [...element.position], radius: element.radius, scale: [...element.scale] })) }; const redo = await call(page, { type: "applyCommand", payload: { type: "redo" } }); const afterRedo = { revision: redo.snapshot.revision, elements: redo.snapshot.nonMeshData.find((entry: any) => entry.id === opened.dataId).elements.map((element: any) => ({ type: element.type, position: [...element.position], radius: element.radius, scale: [...element.scale] })) }; const malformed = await call(page, { type: "applyCommand", payload: { type: "setMetaballElements", dataId: opened.dataId, elements: [{ ...elements[0], radius: -1 }, ...elements.slice(1)] } }).then(() => ({ code: "NO_ERROR" })).catch((error: any) => ({ code: error?.code, message: error?.message })); const afterMalformed = (await readMetaball(page)).state; await exposeWebTools(page); const download = page.waitForEvent("download", { timeout: 30_000 }); await flow.command(() => page.getByRole("button", { name: "保存项目", exact: true }).click()); const savedPath = path.join(directory, "after.blend"); await (await download).saveAs(savedPath); await load(savedPath); const afterReopen = (await readMetaball(page)).state; const savedSha256 = crypto.createHash("sha256").update(fs.readFileSync(savedPath)).digest("hex"); result = { fixtureSha256, dataId: opened.dataId, before, afterEdit, afterUndo, afterRedo, malformed, afterMalformed, afterReopen, savedSha256 }; } catch (error: any) { result = { error: { code: error?.code, message: error?.message, detail: String(error) } }; } const receiptPath = path.join(receipts, "metaball-elements-production.json"); fs.writeFileSync(receiptPath, `${JSON.stringify({ schemaVersion: 1, task: "WBV2-P1137-001", operation: "METABALL_ELEMENTS_MAIN_CHROMIUM", browser: "chromium", queueMutation: false, acceptance: "NOT_CLAIMED", ...result }, null, 2)}\n`); if (!result.error) { const observations = { positive: { before: digest(result.before.elements), after: digest(result.afterEdit.elements) }, negative: { before: digest(result.afterEdit.elements), after: digest(result.afterMalformed.elements) }, undo_redo: { before: digest(result.before.elements), after: digest(result.afterEdit.elements), undo: digest(result.afterUndo.elements), redo: digest(result.afterRedo.elements) }, save_reopen: { saved: digest(result.afterEdit.elements), reopened: digest(result.afterReopen.elements) }, determinism: { first: digest(result.afterEdit.elements), second: digest(result.afterRedo.elements) }, }; for (const kind of kinds) fs.writeFileSync(path.join(receipts, `metaball-elements-${kind}.json`), `${JSON.stringify({ schemaVersion: 1, task: "WBV2-P1137-001", operation: "METABALL_ELEMENTS_MAIN_CHROMIUM", browser: "chromium", runtime: "CHROMIUM_WEBENGINE", acceptance: "NOT_CLAIMED", status: "PASS", caseId: `metaball-elements-${kind}`, kind, unitIds: ["web-command:setMetaballElements"], observations: observations[kind], }, null, 2)}\n`); } expect(result.error).toBeUndefined(); expect(result.fixtureSha256).toBe(fixtureSha256); expect(result.afterEdit.revision).toBe(result.before.revision + 1); expect(result.afterEdit.elements[0].radius).toBeGreaterThan(result.before.elements[0].radius); expect(result.afterUndo.elements).toEqual(result.before.elements); expect(result.afterRedo.elements).toEqual(result.afterEdit.elements); expect(result.malformed.code).not.toBe("NO_ERROR"); expect(result.afterMalformed.elements).toEqual(result.afterRedo.elements); expect(result.afterReopen.elements).toEqual(result.afterRedo.elements); expect(result.savedSha256).not.toBe(fixtureSha256); });