#!/usr/bin/env node import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import net from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { AUDIT_SHAPE_FIELDS, EVIDENCE_SHAPE_FIELDS } from "../internal/sim/l2-runtime.mjs"; import { ERROR_CODES } from "../internal/protocol/index.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const children = []; function logOk(name) { process.stdout.write(`[l2-runtime-smoke] ok ${name}\n`); } function freePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.on("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : null; server.close((error) => { if (error) { reject(error); } else { resolve(port); } }); }); }); } function startNode(relativeScript, env = {}) { const child = spawn(process.execPath, [relativeScript], { cwd: repoRoot, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] }); const output = { stdout: "", stderr: "" }; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk) => { output.stdout += chunk; }); child.stderr.on("data", (chunk) => { output.stderr += chunk; }); const record = { child, relativeScript, output }; children.push(record); return record; } async function stopNode(record) { const { child } = record; if (child.exitCode !== null || child.signalCode !== null) { return; } await new Promise((resolve) => { const killTimer = setTimeout(() => { child.kill("SIGKILL"); }, 1500); child.once("exit", () => { clearTimeout(killTimer); resolve(); }); child.kill("SIGTERM"); }); } async function requestJson(url, options = {}) { const response = await fetch(url, { ...options, headers: { "content-type": "application/json", ...(options.headers ?? {}) } }); const text = await response.text(); const body = text ? JSON.parse(text) : null; return { response, body }; } async function waitForHttp(url, record) { const startedAt = Date.now(); let lastError; while (Date.now() - startedAt < 5000) { if (record.child.exitCode !== null) { throw new Error( `${record.relativeScript} exited before readiness\nstdout:\n${record.output.stdout}\nstderr:\n${record.output.stderr}` ); } try { const { response } = await requestJson(url); if (response.ok) { return; } lastError = new Error(`HTTP ${response.status}`); } catch (error) { lastError = error; } await new Promise((resolve) => setTimeout(resolve, 75)); } throw new Error( `${record.relativeScript} did not become ready at ${url}: ${lastError?.message ?? "timeout"}\nstdout:\n${record.output.stdout}\nstderr:\n${record.output.stderr}` ); } function assertShape(record, fields, name) { assert.ok(record && typeof record === "object", `${name} must be an object`); for (const field of fields) { assert.ok(Object.hasOwn(record, field), `${name}.${field} is required`); } } function assertBoxStatus(status, { boxId, resourceId }) { assert.equal(status.serviceId, "hwlab-box-simu"); assert.equal(status.boxId, boxId); assert.equal(status.resource.resourceId, resourceId); assert.equal(status.environment, "dev"); assert.equal(status.constraints.crossDevicePropagation, "patch-panel-only"); assert.equal(status.constraints.localLoopbackEnabled, false); for (const port of ["AI1", "AO1", "DI1", "DO1", "FREQ1"]) { assert.ok(status.ports[port], `${boxId} has ${port}`); assert.equal(status.ports[port].internalPortApi, true, `${boxId} ${port} internal API`); } } function assertGatewayStatus(status, { gatewayId, resourceIds }) { assert.equal(status.serviceId, "hwlab-gateway-simu"); assert.equal(status.gatewayId, gatewayId); assert.equal(status.session.gatewayId, gatewayId); assert.equal(status.session.status, "connected"); assert.equal(status.session.environment, "dev"); assert.deepEqual(status.boxes, resourceIds); } async function main() { const ports = { boxA: await freePort(), boxB: await freePort(), gatewayA: await freePort(), gatewayB: await freePort() }; const base = { boxA: `http://127.0.0.1:${ports.boxA}`, boxB: `http://127.0.0.1:${ports.boxB}`, gatewayA: `http://127.0.0.1:${ports.gatewayA}`, gatewayB: `http://127.0.0.1:${ports.gatewayB}` }; const boxA = startNode("cmd/hwlab-box-simu/main.mjs", { PORT: String(ports.boxA), HWLAB_BOX_ID: "box-a", HWLAB_BOX_RESOURCE_ID: "res_box-a", HWLAB_GATEWAY_SESSION_ID: "gws_gateway-a" }); const boxB = startNode("cmd/hwlab-box-simu/main.mjs", { PORT: String(ports.boxB), HWLAB_BOX_ID: "box-b", HWLAB_BOX_RESOURCE_ID: "res_box-b", HWLAB_GATEWAY_SESSION_ID: "gws_gateway-b" }); const gatewayA = startNode("cmd/hwlab-gateway-simu/main.mjs", { PORT: String(ports.gatewayA), HWLAB_GATEWAY_ID: "gateway-a", HWLAB_BOX_RESOURCES: "res_box-a", HWLAB_BOX_IDS: "box-a", HWLAB_BOX_URLS: base.boxA }); const gatewayB = startNode("cmd/hwlab-gateway-simu/main.mjs", { PORT: String(ports.gatewayB), HWLAB_GATEWAY_ID: "gateway-b", HWLAB_BOX_RESOURCES: "res_box-b", HWLAB_BOX_IDS: "box-b", HWLAB_BOX_URLS: base.boxB }); await Promise.all([ waitForHttp(`${base.boxA}/health/live`, boxA), waitForHttp(`${base.boxB}/health/live`, boxB), waitForHttp(`${base.gatewayA}/health/live`, gatewayA), waitForHttp(`${base.gatewayB}/health/live`, gatewayB) ]); const [boxAStatus, boxBStatus, gatewayAStatus, gatewayBStatus] = await Promise.all([ requestJson(`${base.boxA}/status`), requestJson(`${base.boxB}/status`), requestJson(`${base.gatewayA}/status`), requestJson(`${base.gatewayB}/status`) ]); assertBoxStatus(boxAStatus.body, { boxId: "box-a", resourceId: "res_box-a" }); assertBoxStatus(boxBStatus.body, { boxId: "box-b", resourceId: "res_box-b" }); assertGatewayStatus(gatewayAStatus.body, { gatewayId: "gateway-a", resourceIds: ["res_box-a"] }); assertGatewayStatus(gatewayBStatus.body, { gatewayId: "gateway-b", resourceIds: ["res_box-b"] }); assert.notEqual(gatewayAStatus.body.session.gatewaySessionId, gatewayBStatus.body.session.gatewaySessionId); logOk("multi-instance identity"); const registerB = await requestJson(`${base.gatewayB}/register`, { method: "POST", body: JSON.stringify({ boxId: "box-b", resourceId: "res_box-b", url: base.boxB }) }); assert.equal(registerB.response.status, 200); assert.equal(registerB.body.accepted, true); assert.equal(registerB.body.registered.boxId, "box-b"); const heartbeatA = await requestJson(`${base.gatewayA}/heartbeat`, { method: "POST", body: JSON.stringify({ reportedBy: "l2-smoke" }) }); assert.equal(heartbeatA.response.status, 200); assert.equal(heartbeatA.body.accepted, true); assert.equal(heartbeatA.body.heartbeat.boxCount, 1); logOk("register and heartbeat"); const [capA, capB] = await Promise.all([ requestJson(`${base.gatewayA}/capabilities`), requestJson(`${base.gatewayB}/capabilities`) ]); assert.equal(capA.response.status, 200); assert.equal(capB.response.status, 200); assert.equal(capA.body.boxes[0].resourceId, "res_box-a"); assert.equal(capB.body.boxes[0].resourceId, "res_box-b"); for (const report of [capA.body, capB.body]) { const names = new Set(report.boxes.flatMap((box) => box.capabilities.map((capability) => capability.name))); for (const name of ["ai.read", "ao.write", "di.read", "do.write", "freq.read", "freq.write"]) { assert.equal(names.has(name), true, `capability report includes ${name}`); } } logOk("capability report"); const aiWrite = await requestJson(`${base.boxA}/ports/write`, { method: "POST", body: JSON.stringify({ operationId: "op_l2_ai1_write", traceId: "trc_l2_ai1_write", port: "AI1", value: 1.25, source: "direct-smoke" }) }); assert.equal(aiWrite.response.status, 200); assert.equal(aiWrite.body.value, 1.25); const aiRead = await requestJson(`${base.boxA}/ports/read`, { method: "POST", body: JSON.stringify({ operationId: "op_l2_ai1_read", traceId: "trc_l2_ai1_read", port: "AI1" }) }); assert.equal(aiRead.response.status, 200); assert.equal(aiRead.body.value, 1.25); const aoWrite = await requestJson(`${base.gatewayA}/invoke`, { method: "POST", body: JSON.stringify({ operationId: "op_l2_ao1_write", traceId: "trc_l2_ao1_write", resourceId: "res_box-a", operation: "port.write", port: "AO1", value: 3.3 }) }); assert.equal(aoWrite.response.status, 200); assert.equal(aoWrite.body.result.value, 3.3); const freqWrite = await requestJson(`${base.gatewayA}/invoke`, { method: "POST", body: JSON.stringify({ operationId: "op_l2_freq1_write", traceId: "trc_l2_freq1_write", resourceId: "res_box-a", operation: "port.write", port: "FREQ1", value: 1000 }) }); assert.equal(freqWrite.response.status, 200); assert.equal(freqWrite.body.result.value, 1000); const freqRead = await requestJson(`${base.gatewayA}/invoke`, { method: "POST", body: JSON.stringify({ operationId: "op_l2_freq1_read", traceId: "trc_l2_freq1_read", resourceId: "res_box-a", operation: "port.read", port: "FREQ1" }) }); assert.equal(freqRead.response.status, 200); assert.equal(freqRead.body.result.value, 1000); logOk("AI/AO/FREQ state read-write"); const directWrite = await requestJson(`${base.boxA}/ports/write`, { method: "POST", body: JSON.stringify({ operationId: "op_l2_direct_do1", traceId: "trc_l2_direct_do1", port: "DO1", value: true, source: "direct-smoke" }) }); assert.equal(directWrite.response.status, 200); assert.equal(directWrite.body.accepted, true); assert.equal(directWrite.body.port, "DO1"); assert.equal(directWrite.body.value, true); assert.equal(directWrite.body.crossDevicePropagation, "patch-panel-only"); assert.equal(directWrite.body.localLoopbackEnabled, false); assertShape(directWrite.body.audit, AUDIT_SHAPE_FIELDS, "directWrite.audit"); assertShape(directWrite.body.evidence, EVIDENCE_SHAPE_FIELDS, "directWrite.evidence"); const internalWrite = await requestJson(`${base.boxB}/internal/ports/write`, { method: "POST", body: JSON.stringify({ operationId: "op_l2_internal_di1", traceId: "trc_l2_internal_di1", port: "DI1", value: true, sourceResourceId: "res_box-a", sourcePort: "DO1", propagatedBy: "hwlab-patch-panel", source: "patch-panel" }) }); assert.equal(internalWrite.response.status, 200); assert.equal(internalWrite.body.accepted, true); assert.equal(internalWrite.body.port, "DI1"); assert.equal(internalWrite.body.value, true); assertShape(internalWrite.body.audit, AUDIT_SHAPE_FIELDS, "internalWrite.audit"); assertShape(internalWrite.body.evidence, EVIDENCE_SHAPE_FIELDS, "internalWrite.evidence"); logOk("direct call and internal port API"); const invoke = await requestJson(`${base.gatewayB}/invoke`, { method: "POST", body: JSON.stringify({ operationId: "op_l2_gateway_read_di1", traceId: "trc_l2_gateway_read_di1", resourceId: "res_box-b", operation: "port.read", port: "DI1" }) }); assert.equal(invoke.response.status, 200); assert.equal(invoke.body.accepted, true); assert.equal(invoke.body.gatewayId, "gateway-b"); assert.equal(invoke.body.target.resourceId, "res_box-b"); assert.equal(invoke.body.result.value, true); assertShape(invoke.body.audit, AUDIT_SHAPE_FIELDS, "invoke.audit"); assertShape(invoke.body.evidence, EVIDENCE_SHAPE_FIELDS, "invoke.evidence"); assertShape(invoke.body.result.audit, AUDIT_SHAPE_FIELDS, "invoke.result.audit"); assertShape(invoke.body.result.evidence, EVIDENCE_SHAPE_FIELDS, "invoke.result.evidence"); logOk("gateway invoke forwarding"); const notRegistered = await requestJson(`${base.gatewayA}/invoke`, { method: "POST", body: JSON.stringify({ operationId: "op_l2_wrong_gateway", traceId: "trc_l2_wrong_gateway", resourceId: "res_box-b", operation: "port.read", port: "DI1" }) }); assert.equal(notRegistered.response.status, 404); assert.equal(notRegistered.body.error.code, ERROR_CODES.sessionNotFound); assert.equal(notRegistered.body.error.data.gatewayId, "gateway-a"); const badCapability = await requestJson(`${base.boxA}/invoke`, { method: "POST", body: JSON.stringify({ operationId: "op_l2_bad_capability", traceId: "trc_l2_bad_capability", operation: "port.read", capabilityId: "cap_box-a_missing" }) }); assert.equal(badCapability.response.status, 404); assert.equal(badCapability.body.error.code, ERROR_CODES.capabilityUnavailable); assert.equal(badCapability.body.error.data.reason, "capability_unavailable"); const capabilityPortMismatch = await requestJson(`${base.boxA}/invoke`, { method: "POST", body: JSON.stringify({ operationId: "op_l2_capability_mismatch", traceId: "trc_l2_capability_mismatch", operation: "port.read", capabilityId: "cap_box-a_di1_read", port: "DO1" }) }); assert.equal(capabilityPortMismatch.response.status, 400); assert.equal(capabilityPortMismatch.body.error.code, ERROR_CODES.invalidParams); assert.equal(capabilityPortMismatch.body.error.data.reason, "capability_port_mismatch"); logOk("error codes"); const finalBoxA = await requestJson(`${base.boxA}/status`); const finalBoxB = await requestJson(`${base.boxB}/status`); assert.equal(finalBoxA.body.ports.DI1.value, false, "box-a DI1 must not loop back from its DO1 write"); assert.equal(finalBoxB.body.ports.DI1.value, true, "box-b DI1 changes only through internal port API"); assert.equal(finalBoxB.body.ports.DI1.propagatedBy, "hwlab-patch-panel"); logOk("patch-panel-only propagation boundary"); process.stdout.write("[l2-runtime-smoke] passed\n"); } try { await main(); } finally { await Promise.allSettled(children.map(stopNode)); }