import assert from "node:assert/strict"; import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { once } from "node:events"; import test from "node:test"; import { createDeploymentHttpServer } from "../../../tools/web/deployment-http-server.mjs"; const digest = (value) => crypto.createHash("sha256").update(value).digest("hex"); const canonical = (value) => JSON.stringify(value, (_, item) => item && !Array.isArray(item) && typeof item === "object" ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) : item); test("async HTTP provider lifecycle (transport double, not Blender evidence)", async (t) => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "operator-http-test-")); const options = { workspaceRoot, pythonBinary: path.resolve(import.meta.dirname, "../fixtures/server-operator-runner.py") }; const server = createDeploymentHttpServer({ root: workspaceRoot, serverOperator: options }); server.listen(0, "127.0.0.1"); await once(server, "listening"); const url = `http://127.0.0.1:${server.address().port}/api/server-operator-job`; const requests = []; async function call(action, request, body) { const response = await fetch(action === "read" ? `${url}?jobId=${request.jobId}&requestSha256=${request.requestSha256}` : url, { method: action === "read" ? "GET" : "POST", headers: { "X-Server-Operator-Action": action }, body: action === "read" ? undefined : body ?? JSON.stringify(request), signal: AbortSignal.timeout(5000), }); return { code: response.status, ...await response.json() }; } function envelope(mode, overrides = {}) { const source = Buffer.from("transport-source"); const properties = { mode }; const request = { schemaVersion: 1, route: "SERVER_NATIVE", jobId: crypto.randomUUID(), sourceBlendSha256: digest(source), sourceBlendByteLength: source.length, sourceRevision: 1, blenderBuild: { version: "test", buildSha256: "a".repeat(64) }, operator: "object.test", properties, propertiesSha256: digest(canonical(properties)), timeoutMs: 1000, memoryBytes: 64 * 1024 * 1024, resultBinding: { kind: "snapshot", key: "test" }, context: "BACKGROUND", ...overrides }; request.requestSha256 = digest(canonical(request)); const json = Buffer.from(JSON.stringify(request)); const header = Buffer.alloc(16); header.write("WBJ1"); header.writeUInt32LE(1, 4); header.writeUInt32LE(json.length, 8); header.writeUInt32LE(source.length, 12); requests.push(request); return { request, body: Buffer.concat([header, json, source]) }; } async function terminal(request) { for (let i = 0; i < 80; i++) { const reply = await call("read", request); if (reply.result.status !== "RUNNING") return reply.result; await new Promise((resolve) => setTimeout(resolve, 50)); } assert.fail("job did not reach a terminal state"); } try { await t.test("deployment reserves the API and rejects invalid methods/actions before execution", async () => { const invalid = await call("unknown", {}); assert.equal(invalid.code, 400); for (const action of ["describe", "submit", "cancel"]) { const response = await fetch(url, { headers: { "X-Server-Operator-Action": action } }); assert.equal(response.status, 405); assert.equal(response.headers.get("allow"), "POST"); assert.equal(response.headers.get("cache-control"), "no-store"); assert.equal(response.headers.get("cross-origin-opener-policy"), "same-origin"); } assert.equal((await fetch(`${url}?jobId=missing&requestSha256=missing`)).status, 404); assert.equal((await fetch(new URL("/index.html", url), { method: "POST" })).status, 405); const malformed = await call("submit", {}, Buffer.from("invalid envelope")); assert.equal(malformed.code, 400); const versioned = envelope("success"); versioned.body.writeUInt32LE(2, 4); assert.equal((await call("submit", versioned.request, versioned.body)).code, 400); assert.equal((await call("read", versioned.request)).code, 404); assert.deepEqual(fs.readdirSync(workspaceRoot), []); }); await t.test("202, concurrent reads, success, immutable terminal state and cleanup", async () => { const { request, body } = envelope("success"); const submitted = await call("submit", request, body); assert.equal(submitted.code, 202); assert.equal(submitted.result.status, "RUNNING"); assert.equal((await call("read", request)).result.status, "RUNNING"); assert.equal((await call("submit", request, body)).code, 409); const replies = await Promise.all([terminal(request), terminal(request)]); assert.deepEqual(replies[0], replies[1]); assert.equal(replies[0].status, "SUCCEEDED"); assert.equal(Buffer.from(replies[0].resultBlendBase64, "base64").toString(), "transport-result"); assert.equal((await call("cancel", request)).result.status, "SUCCEEDED"); assert.deepEqual(fs.readdirSync(workspaceRoot), []); }); await t.test("cancel is idempotent; no late success or result bytes", async () => { const { request, body } = envelope("slow"); await call("submit", request, body); await Promise.all([call("cancel", request), call("read", request)]); assert.equal((await call("cancel", request)).result.status, "CANCELLED"); const result = await terminal(request); assert.equal(result.status, "CANCELLED"); assert.equal(result.resultBlendBase64, undefined); assert.deepEqual(fs.readdirSync(workspaceRoot), []); }); for (const mode of ["signal", "slow", "malformed", "bad-hash", "bad-binding"]) { await t.test(`${mode} fails closed and cleans without polling`, async () => { const { request, body } = envelope(mode); assert.equal((await call("submit", request, body)).code, 202); await new Promise((resolve) => setTimeout(resolve, mode === "slow" ? 1600 : 650)); assert.deepEqual(fs.readdirSync(workspaceRoot), []); const result = await terminal(request); assert.equal(result.status, "FAILED"); assert.equal(result.resultBlendBase64, undefined); }); } await t.test("missing executable does not crash the HTTP provider", async () => { options.pythonBinary = path.join(workspaceRoot, "missing-python"); const { request, body } = envelope("success"); assert.equal((await call("submit", request, body)).code, 202); assert.equal((await terminal(request)).status, "FAILED"); }); } finally { for (const request of requests) await call("cancel", request).catch(() => {}); server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); fs.rmSync(workspaceRoot, { recursive: true, force: true }); } });