Files
pikasTech-HWLAB/scripts/l2-runtime-contract-smoke.mjs
Lyon 7a0648088c fix: wire distinct M3 simulator identities
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.
2026-05-23 00:09:29 +08:00

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));
}