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>
187 lines
14 KiB
TypeScript
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()));
|
|
}
|
|
});
|