import { expect, test } from "@playwright/test"; import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { createDeploymentHttpServer, listenDeploymentHttpServer } from "../../../tools/web/deployment-http-server.mjs"; const root = path.resolve(import.meta.dirname, "../../.."); const fixturePath = "tests/files/web/generated/WBV2-P24-001-OBJECT_OPERATIONS.MOVE_COLLECTION.blend"; const fixture = fs.readFileSync(path.join(root, fixturePath)); test("SERVER_NATIVE async HTTP mutates Blender Main and reopens in WASM", async ({ page }, testInfo) => { test.setTimeout(120_000); const actions: string[] = []; const submissions: number[] = []; const wasmPaths = new Set(); page.on("request", (request) => { const action = request.headers()["x-server-operator-action"]; if (action) actions.push(action); const pathname = new URL(request.url()).pathname; if (pathname.startsWith("/vendor/blender/") && pathname.endsWith(".wasm")) wasmPaths.add(`web/app/public${pathname}`); }); page.on("response", (response) => { if (response.request().headers()["x-server-operator-action"] === "submit") submissions.push(response.status()); }); await page.goto("/"); const result = await page.evaluate(async (bytes) => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const client = new WebEngineClient({ timeoutMs: 30_000 }); const reopened = new WebEngineClient({ timeoutMs: 30_000 }); const route = { reason: "Native deletion workflow", id: "operator:object.delete", route: "SERVER_NATIVE" as const, identifier: "object.delete", kind: "operator", family: "OBJECT" }; try { const before = await client.openBlend(Uint8Array.from(bytes).buffer); const local = await client.applyCommand({ type: "setFrame", frame: 7 }); const negative = await client.invokeFeatureRoute({ route, properties: { nonexistent_property: true } }).then(() => "unexpected success", (error) => error.message); const afterNegative = await client.snapshot(); const changed = await client.invokeFeatureRoute({ route, properties: { use_global: false } }); const undone = await client.applyCommand({ type: "undo" }); const undoLocal = await client.applyCommand({ type: "undo" }); const redoLocal = await client.applyCommand({ type: "redo" }); const redone = await client.applyCommand({ type: "redo" }); const invalid = await client.applyServerBlendResult(new TextEncoder().encode("invalid blend").buffer, redone.snapshot.revision).then(() => "unexpected success", (error) => error.message); const afterInvalid = await client.snapshot(); const stale = await client.applyServerBlendResult(Uint8Array.from(bytes).buffer, before.snapshot.revision).then(() => "unexpected success", (error) => error.message); const afterStale = await client.snapshot(); const saved = await client.saveBlend(); const restored = await reopened.openBlend(saved); return { before: before.snapshot.nodes.map((node) => node.name), negative, negativeUnchanged: JSON.stringify(local.snapshot) === JSON.stringify(afterNegative.snapshot), after: changed.snapshot.nodes.map((node) => node.name), restored: restored.snapshot.nodes.map((node) => node.name), status: (changed.operatorResult as { status: string }).status, undone: undone.snapshot.nodes.map((node) => node.name), redone: redone.snapshot.nodes.map((node) => node.name), localFrames: [before.snapshot.frame.current, local.snapshot.frame.current, undoLocal.snapshot.frame.current, redoLocal.snapshot.frame.current], revisions: [before.snapshot.revision, local.snapshot.revision, changed.snapshot.revision, undone.snapshot.revision, undoLocal.snapshot.revision, redoLocal.snapshot.revision, redone.snapshot.revision], invalid, invalidUnchanged: JSON.stringify(afterInvalid.snapshot) === JSON.stringify(redone.snapshot), stale, staleUnchanged: JSON.stringify(afterStale.snapshot) === JSON.stringify(redone.snapshot) }; } catch (error) { throw new Error(JSON.stringify(error), { cause: error }); } finally { client.terminate(); reopened.terminate(); } }, Array.from(fixture)); await testInfo.attach("native-http-evidence", { body: JSON.stringify({ fixturePath, fixtureSha256: crypto.createHash("sha256").update(fixture).digest("hex"), actions, submissions, ...result }, null, 2), contentType: "application/json" }); expect(result.before).toContain("WBV2P2401Cube"); expect(result.negative).not.toBe("unexpected success"); expect(result.negativeUnchanged).toBe(true); expect(result.status).toBe("SUCCEEDED"); expect(result.after).not.toContain("WBV2P2401Cube"); expect(result.restored).not.toContain("WBV2P2401Cube"); expect(result.undone).toContain("WBV2P2401Cube"); expect(result.redone).not.toContain("WBV2P2401Cube"); expect(result.localFrames).toEqual([1, 7, 1, 7]); expect(result.revisions).toEqual([1, 2, 3, 4, 5, 6, 7]); expect(result.invalid).not.toBe("unexpected success"); expect(result.invalidUnchanged).toBe(true); expect(result.stale).toContain("REVISION_CONFLICT"); expect(result.staleUnchanged).toBe(true); expect(submissions).toEqual([202, 202]); expect(actions).toContain("read"); if (process.env.SERVER_OPERATOR_NATIVE_REPORT) { const artifact = (file: string) => ({ path: file, sha256: crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex") }); const report = { schemaVersion: 1, task: "WBV2-P1424-001", workflow: "SERVER-OPERATOR-HTTP-PROVIDER", status: "PASS", browser: "chromium", fixture: artifact(fixturePath), nativeBinary: artifact("build_blender_native/bin/blender"), wasmArtifacts: [...wasmPaths].map(artifact), actions, submissions, ...result }; fs.writeFileSync(process.env.SERVER_OPERATOR_NATIVE_REPORT, `${JSON.stringify(report, null, 2)}\n`); } }); test("catalog Add Object executes through a disposable deployment and preserves Main history", async ({ page }, testInfo) => { test.setTimeout(180_000); const receiptRoot = process.env.WBV2_RECEIPT_DIR ? path.resolve(process.env.WBV2_RECEIPT_DIR) : testInfo.outputPath("cases"); const artifactRoot = path.join(receiptRoot, "server-operator-artifacts"); const jobsRoot = testInfo.outputPath("server-jobs"); fs.mkdirSync(artifactRoot, { recursive: true }); fs.mkdirSync(jobsRoot, { recursive: true }); const nativeBinary = path.resolve(root, process.env.BLENDER_BIN ?? "build_blender_native/bin/blender"); const deployment = createDeploymentHttpServer({ root: path.join(root, "web/dist"), serverOperator: { blenderBinary: nativeBinary, workspaceRoot: jobsRoot }, }); const origin = await listenDeploymentHttpServer(deployment); const exchanges: { action: string | undefined; status: number }[] = []; const wasmPaths = new Set(); page.on("request", (request) => { const pathname = new URL(request.url()).pathname; if (pathname.startsWith("/vendor/blender/") && pathname.endsWith(".wasm")) wasmPaths.add(`web/app/public${pathname}`); }); // Vite supplies the TS test harness; all API bytes go to the real deployment server. await page.route("**/api/server-operator-job*", async (route) => { const request = route.request(); const url = new URL(request.url()); const response = await route.fetch({ url: `${origin}${url.pathname}${url.search}`, timeout: 120_000 }); exchanges.push({ action: request.headers()["x-server-operator-action"], status: response.status() }); await route.fulfill({ response }); }); try { await page.goto("/"); const result = await page.evaluate(async (bytes) => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const { default: catalog } = await import("/src/capabilities/blender-feature-route-catalog.json"); const entries = catalog.entries; const route = entries.find((entry) => entry.id === "operator:OBJECT_OT_add"); if (!route || route.route !== "SERVER_NATIVE") throw new Error("Add Object catalog route unavailable"); const client = new WebEngineClient({ timeoutMs: 30_000 }); const blends: Record = {}; const capture = async (name: string) => { blends[name] = Array.from(new Uint8Array(await client.saveBlend())); }; try { await client.openBlend(Uint8Array.from(bytes).buffer); await capture("before"); const negative = await client.invokeFeatureRoute({ route, properties: { nonexistent_property: true } }) .then(() => "unexpected success", (error) => String(error.message)); await capture("negative"); const applied = await client.invokeFeatureRoute({ route, properties: { type: "EMPTY" } }); await capture("after"); await client.applyCommand({ type: "undo" }); await capture("undo"); await client.applyCommand({ type: "redo" }); await capture("redo"); await capture("saved"); await client.openBlend(Uint8Array.from(blends.saved).buffer); await capture("reopened"); await client.openBlend(Uint8Array.from(bytes).buffer); const repeated = await client.invokeFeatureRoute({ route, properties: { type: "EMPTY" } }); await capture("second"); return { blends, negative, firstStatus: (applied.operatorResult as { status: string }).status, secondStatus: (repeated.operatorResult as { status: string }).status }; } finally { client.terminate(); } }, Array.from(fixture)); expect(result.negative).not.toBe("unexpected success"); expect(result.firstStatus).toBe("SUCCEEDED"); expect(result.secondStatus).toBe("SUCCEEDED"); expect(exchanges.filter((entry) => entry.action === "submit").map((entry) => entry.status)).toEqual([202, 202, 202]); expect(exchanges.some((entry) => entry.action === "describe" && entry.status === 200)).toBe(true); expect(exchanges.some((entry) => entry.action === "read" && entry.status === 200)).toBe(true); for (const [name, bytes] of Object.entries(result.blends)) fs.writeFileSync(path.join(artifactRoot, `${name}.blend`), Buffer.from(bytes)); execFileSync(nativeBinary, ["--background", "--factory-startup", "--python-exit-code", "1", "--python", path.join(root, "web/tests/fixtures/server-main-snapshots.py"), "--", artifactRoot], { timeout: 120_000, stdio: "pipe" }); const snapshots = JSON.parse(fs.readFileSync(path.join(artifactRoot, "native-main-snapshots.json"), "utf8")); const canonical = (value: unknown): string => JSON.stringify(value, (_key, item) => item && !Array.isArray(item) && typeof item === "object" ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : item); const digest = (value: string | Buffer) => crypto.createHash("sha256").update(value).digest("hex"); const hashes = Object.fromEntries(Object.entries(snapshots).map(([name, snapshot]) => [name, digest(canonical(snapshot))])); expect(snapshots.after.objects.length).toBe(snapshots.before.objects.length + 1); const previousNames = new Set(snapshots.before.objects.map((object: { name: string; type: string }) => object.name)); expect(snapshots.after.objects.filter((object: { name: string; type: string }) => !previousNames.has(object.name)).map((object: { name: string; type: string }) => object.type)).toEqual(["EMPTY"]); expect(hashes.after).not.toBe(hashes.before); expect(hashes.negative).toBe(hashes.before); expect(hashes.undo).toBe(hashes.before); expect(hashes.redo).toBe(hashes.after); expect(hashes.reopened).toBe(hashes.saved); expect(hashes.second).toBe(hashes.after); expect(wasmPaths.size).toBeGreaterThan(0); const compiledWasm = path.join(root, "build_web_blender6/bin/web_engine.wasm"); const compiledHash = digest(fs.readFileSync(compiledWasm)); expect([...wasmPaths].some((file) => digest(fs.readFileSync(path.join(root, file))) === compiledHash)).toBe(true); expect(fs.readdirSync(jobsRoot)).toEqual([]); await page.screenshot({ path: path.join(artifactRoot, "chromium.png") }); fs.writeFileSync(path.join(artifactRoot, "http-exchanges.json"), JSON.stringify(exchanges, null, 2)); const artifact = (file: string) => ({ path: path.relative(root, file), sha256: digest(fs.readFileSync(file)) }); const artifacts = [nativeBinary, compiledWasm, path.join(root, fixturePath), ...[...wasmPaths].map((file) => path.join(root, file)), ...fs.readdirSync(artifactRoot).map((file) => path.join(artifactRoot, file))].map(artifact); const unitIds = ["operator:OBJECT_OT_add", "operator:WM_OT_open_mainfile", "operator:WM_OT_save_as_mainfile"]; const cases = [ { kind: "positive", observations: { before: hashes.before, after: hashes.after } }, { kind: "negative", observations: { before: hashes.before, 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 } }, ]; if (process.env.WBV2_RECEIPT_DIR) { for (const entry of cases) { const caseId = `server-http-${entry.kind}`; fs.writeFileSync(path.join(receiptRoot, `${caseId}.json`), `${JSON.stringify({ status: "PASS", runtime: "CHROMIUM_WEBENGINE", caseId, ...entry, unitIds, artifacts }, null, 2)}\n`); } } } finally { await page.unroute("**/api/server-operator-job*"); deployment.closeAllConnections(); await new Promise((resolve) => deployment.close(() => resolve())); } });