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>
112 lines
7.3 KiB
TypeScript
112 lines
7.3 KiB
TypeScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { execFileSync } from "node:child_process";
|
|
import { expect, test } from "@playwright/test";
|
|
import { createDeploymentHttpServer, listenDeploymentHttpServer } from "../../../tools/web/deployment-http-server.mjs";
|
|
|
|
const root = path.resolve(import.meta.dirname, "../../..");
|
|
const fixture = "tests/files/web/basic_scene.blend";
|
|
const reader = "web/tests/fixtures/sculpt-remesh-main-snapshots.py";
|
|
const unit = "operator:OBJECT_OT_voxel_remesh";
|
|
const hash = (value: string | Buffer) => crypto.createHash("sha256").update(value).digest("hex");
|
|
|
|
test("native voxel remesh has desktop geometry, rollback, history and replay", async ({ page }, testInfo) => {
|
|
test.setTimeout(180_000);
|
|
const receipts = process.env.WBV2_RECEIPT_DIR ?? testInfo.outputPath("receipts");
|
|
const directory = path.join(receipts, "remesh-artifacts");
|
|
const jobs = testInfo.outputPath("jobs");
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
fs.mkdirSync(jobs, { recursive: true });
|
|
const native = path.resolve(root, process.env.BLENDER_BIN ?? "build_blender_native/bin/blender");
|
|
const server = createDeploymentHttpServer({ root: path.join(root, "web/dist"), serverOperator: { blenderBinary: native, workspaceRoot: jobs } });
|
|
const origin = await listenDeploymentHttpServer(server);
|
|
const exchanges: { action?: string; status: number }[] = [];
|
|
try {
|
|
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() });
|
|
fs.writeFileSync(path.join(directory, `http-${exchanges.length}.json`), await response.body());
|
|
await route.fulfill({ response });
|
|
});
|
|
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 route = catalog.entries.find(entry => entry.id === "operator:OBJECT_OT_voxel_remesh");
|
|
if (!route || route.route !== "SERVER_NATIVE") throw new Error("Voxel remesh route missing");
|
|
const client = new WebEngineClient({ timeoutMs: 60_000 });
|
|
const blends: Record<string, number[]> = {};
|
|
const capture = async (stage: string, owner = client) => { blends[stage] = Array.from(new Uint8Array(await owner.saveBlend())); };
|
|
try {
|
|
const opened = await client.openBlend(Uint8Array.from(bytes).buffer);
|
|
const active = opened.snapshot.nodes.find(node => node.id === opened.snapshot.activeObjectId);
|
|
if (active?.type !== "MESH") throw new Error("Fixture requires active mesh");
|
|
await client.applyCommand({ type: "setObjectSelection", objectId: active.id, selected: true, active: true });
|
|
await capture("before");
|
|
const applied = await client.invokeFeatureRoute({ route, properties: { voxel_size: 0.3 } });
|
|
await capture("after");
|
|
let rejection = "NO_ERROR";
|
|
try { await client.invokeFeatureRoute({ route, properties: { voxel_size: -1 } }); }
|
|
catch (error) { rejection = String((error as { message?: string }).message ?? error); }
|
|
const negative = await client.snapshot();
|
|
await capture("negative");
|
|
await client.applyCommand({ type: "undo" });
|
|
await capture("undo");
|
|
await client.applyCommand({ type: "redo" });
|
|
await capture("redo");
|
|
const reopen = new WebEngineClient({ timeoutMs: 60_000 });
|
|
try {
|
|
await reopen.openBlend(Uint8Array.from(blends.redo).buffer);
|
|
await capture("reopened", reopen);
|
|
} finally { reopen.terminate(); }
|
|
const repeat = new WebEngineClient({ timeoutMs: 60_000 });
|
|
try {
|
|
await repeat.openBlend(Uint8Array.from(bytes).buffer);
|
|
await repeat.applyCommand({ type: "setObjectSelection", objectId: active.id, selected: true, active: true });
|
|
await repeat.invokeFeatureRoute({ route, properties: { voxel_size: 0.3 } });
|
|
await capture("second", repeat);
|
|
} finally { repeat.terminate(); }
|
|
return { blends, rejection, applied: applied.snapshot, negative: negative.snapshot };
|
|
} catch (error) {
|
|
const detail = error as { code?: string; message?: string };
|
|
throw new Error(JSON.stringify({ code: detail.code, message: detail.message ?? String(error) }), { cause: error });
|
|
} finally { client.terminate(); }
|
|
}, Array.from(fs.readFileSync(path.join(root, fixture))));
|
|
for (const [stage, bytes] of Object.entries(result.blends)) fs.writeFileSync(path.join(directory, `${stage}.blend`), Buffer.from(bytes));
|
|
fs.writeFileSync(path.join(directory, "http.json"), JSON.stringify(exchanges, null, 2));
|
|
fs.writeFileSync(path.join(directory, "browser.json"), JSON.stringify({ ...result, blends: undefined }, null, 2));
|
|
expect(result.rejection).not.toBe("NO_ERROR");
|
|
expect(result.negative).toEqual(result.applied);
|
|
expect(exchanges.some(entry => entry.action === "submit" && entry.status === 202)).toBe(true);
|
|
const log = fs.openSync(path.join(directory, "desktop.log"), "w");
|
|
try {
|
|
execFileSync(native, ["--background", "--factory-startup", "--python-exit-code", "1", "--python", path.join(root, reader), "--", directory], { timeout: 60_000, stdio: ["ignore", log, log] });
|
|
} finally { fs.closeSync(log); }
|
|
const states = JSON.parse(fs.readFileSync(path.join(directory, "native.json"), "utf8"));
|
|
expect(states.after).not.toEqual(states.before);
|
|
expect(states.after).toEqual(states.expected);
|
|
expect(states.after.objects.find((obj: { name: string }) => obj.name === "BasicCube").vertices.length).toBeGreaterThan(8);
|
|
for (const stage of ["negative", "redo", "reopened", "second"]) expect(states[stage]).toEqual(states.after);
|
|
expect(states.undo).toEqual(states.before);
|
|
const digest = (stage: string) => hash(JSON.stringify(states[stage]));
|
|
const observations = {
|
|
positive: { before: digest("before"), after: digest("after") },
|
|
negative: { before: digest("after"), 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/sculpt-remesh-native.spec.ts", path.relative(root, native), "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 = `sculpt-remesh-${kind}`;
|
|
fs.writeFileSync(path.join(receipts, `${caseId}.json`), JSON.stringify({ status: "PASS", runtime: "CHROMIUM_WEBENGINE", caseId, kind, unitIds: [unit], observations: values, artifacts }, null, 2) + "\n");
|
|
}
|
|
} finally {
|
|
server.closeAllConnections();
|
|
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
|
}
|
|
});
|