Files
workinf_Blender_Wasm/web/tests/e2e/server-operator-native.spec.ts
mes123456 f3e21c6b98
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender migration source, tools and docs
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>
2026-11-10 11:18:01 +08:00

187 lines
14 KiB
TypeScript

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<string>();
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<string>();
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<string, number[]> = {};
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<void>((resolve) => deployment.close(() => resolve()));
}
});