7a0648088c
Merge PR #159 after Code Queue rebase and validation. Runner verified M3 cardinality, hardware-loop smoke, patch-panel runtime smoke, m3 readonly contract, and git diff check. This is source/config blocker removal only; it does not claim DEV-LIVE M3 pass.
440 lines
14 KiB
JavaScript
440 lines
14 KiB
JavaScript
#!/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));
|
|
}
|