import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { execFileSync } from "node:child_process"; import { expect, test } from "@playwright/test"; import { NativeUIWorkflow, observeNative } from "../fixtures/native-ui-workflow"; const root = path.resolve(import.meta.dirname, "../../.."); const fixture = "tests/files/web/basic_scene.blend"; const reader = "web/tests/fixtures/operator-bridge-main.py"; const hash = (value: Buffer | string) => crypto.createHash("sha256").update(value).digest("hex"); test.afterEach(async ({ page }, testInfo) => { if (testInfo.status === testInfo.expectedStatus) return; const button = page.getByTestId("export-diagnostics"); if (await button.isEnabled()) { const download = page.waitForEvent("download"); await button.click(); await (await download).saveAs(testInfo.outputPath("diagnostics.json")); } }); test("F3 native operator properties, rejection and Main history", async ({ page }, testInfo) => { test.setTimeout(90_000); const receipts = process.env.WBV2_RECEIPT_DIR ?? testInfo.outputPath("receipts"); const directory = path.join(receipts, "operator-palette-artifacts"); await observeNative(page); const flow = new NativeUIWorkflow(page, directory); await page.goto("/"); await flow.load(path.join(root, fixture), "BasicCube"); await flow.save("before"); const dialog = page.getByRole("dialog", { name: "操作搜索", exact: true }); const choose = async () => { await page.getByRole("button", { name: "操作搜索", exact: true }).click(); await dialog.getByRole("textbox", { name: "搜索 Blender 操作" }).fill("mesh.primitive_cube_add"); await dialog.getByRole("button").filter({ hasText: "[WEB_MAIN]" }).first().click(); await expect(dialog.locator("form")).toBeVisible(); }; const execute = async () => { await choose(); await dialog.locator("form").getByLabel("name", { exact: true }).fill("BridgePrimitive"); await flow.click(dialog.getByRole("button", { name: "执行", exact: true })); await expect(dialog).toBeHidden(); }; await choose(); await dialog.locator("form").getByLabel("name", { exact: true }).fill("x".repeat(64)); await flow.click(dialog.getByRole("button", { name: "执行", exact: true }), false); await expect(dialog.getByRole("alert")).toBeVisible(); await dialog.getByRole("button", { name: "关闭操作搜索" }).click(); await flow.save("negative"); await execute(); await flow.save("after"); await flow.click(page.getByRole("button", { name: "撤销", exact: true })); await flow.save("undo"); await flow.click(page.getByRole("button", { name: "重做", exact: true })); await flow.save("redo"); await flow.load(path.join(directory, "redo.blend"), "BridgePrimitive"); await flow.save("reopened"); // Reloading the page creates a fresh worker, rather than reusing the first engine. await page.reload(); await flow.load(path.join(root, fixture), "BasicCube"); await execute(); await flow.save("second"); await page.screenshot({ path: path.join(directory, "palette.png") }); fs.writeFileSync(path.join(directory, "primitive.json"), JSON.stringify({ primitive: "cube" })); const binary = path.resolve(root, process.env.BLENDER_BIN ?? "build_blender_native/bin/blender"); const log = fs.openSync(path.join(directory, "desktop.log"), "w"); try { execFileSync(binary, ["--background", "--factory-startup", "--python-exit-code", "1", "--python", path.join(root, reader), "--", directory], { timeout: 30_000, stdio: ["ignore", log, log] }); } finally { fs.closeSync(log); } const states = JSON.parse(fs.readFileSync(path.join(directory, "native.json"), "utf8")); expect(states.after).toEqual(states.expected); expect(states.after).not.toEqual(states.before); expect(states.negative).toEqual(states.before); expect(states.undo).toEqual(states.before); for (const stage of ["redo", "reopened", "second"]) expect(states[stage]).toEqual(states.after); const digest = (stage: string) => hash(JSON.stringify(states[stage])); const observations = { positive: { before: digest("before"), after: digest("after") }, negative: { before: digest("before"), after: digest("negative") }, undo_redo: { before: digest("before"), after: digest("after"), undo: digest("undo"), redo: digest("redo") }, save_reopen: { saved: digest("redo"), reopened: digest("reopened") }, determinism: { first: digest("after"), second: digest("second") }, }; const artifacts = [fixture, reader, "web/tests/e2e/operator-palette-native.spec.ts", path.relative(root, binary), "web/app/public/vendor/blender/web_engine.wasm", ...fs.readdirSync(directory).map(name => path.relative(root, path.join(directory, name)))].map(file => ({ path: file, sha256: hash(fs.readFileSync(path.join(root, file))) })); for (const [kind, values] of Object.entries(observations)) { const caseId = `operator-palette-${kind}`; fs.writeFileSync(path.join(receipts, `${caseId}.json`), JSON.stringify({ status: "PASS", runtime: "CHROMIUM_WEBENGINE", caseId, kind, unitIds: ["web-command:createPrimitive", "operator:WM_OT_open_mainfile"], observations: values, artifacts }, null, 2) + "\n"); } });