Files
pikasTech-HWLAB/scripts/patch-panel-runtime-smoke.mjs
T
2026-05-21 18:36:39 +00:00

252 lines
8.3 KiB
JavaScript

#!/usr/bin/env node
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { request as httpRequest } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const fixturePath = path.join(repoRoot, "fixtures/mvp/patch-panel-runtime/two-box-sync.json");
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
async function freePort() {
const { createServer } = await import("node:net");
return new Promise((resolve, reject) => {
const server = 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(() => {
if (!port) {
reject(new Error("failed to allocate a free port"));
return;
}
resolve(port);
});
});
});
}
function startNode(scriptPath, env) {
const child = spawn(process.execPath, [scriptPath], {
cwd: repoRoot,
env: {
...process.env,
...env
},
stdio: ["ignore", "pipe", "pipe"]
});
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
return child;
}
function stopProcess(child) {
if (!child || child.exitCode !== null) {
return Promise.resolve();
}
return new Promise((resolve) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
resolve();
}, 2000);
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
child.kill("SIGTERM");
});
}
async function requestJson(url, options = {}) {
const body = options.body ? JSON.stringify(options.body) : null;
return new Promise((resolve, reject) => {
const req = httpRequest(
new URL(url),
{
method: options.method ?? (body ? "POST" : "GET"),
headers: body
? {
"content-type": "application/json",
"content-length": Buffer.byteLength(body)
}
: {},
timeout: options.timeoutMs ?? 5000
},
(response) => {
let text = "";
response.setEncoding("utf8");
response.on("data", (chunk) => {
text += chunk;
});
response.on("end", () => {
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
resolve({
response,
status: response.statusCode ?? 0,
body: json,
text
});
});
}
);
req.on("timeout", () => {
req.destroy(new Error(`request timed out for ${url}`));
});
req.on("error", reject);
if (body) {
req.write(body);
}
req.end();
});
}
async function waitForHttp(url, child) {
const startedAt = Date.now();
let lastError = null;
while (Date.now() - startedAt < 5000) {
if (child.exitCode !== null) {
throw new Error(`process exited before ${url} became ready`);
}
try {
const result = await requestJson(url, { timeoutMs: 500 });
if (result.status >= 200 && result.status < 300) {
return result;
}
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`timed out waiting for ${url}: ${lastError?.message ?? "no response"}`);
}
function assertPatchPanelDiagnostics(diagnostics) {
assert.equal(diagnostics.serviceId, "hwlab-patch-panel");
assert.equal(diagnostics.environment, "dev");
assert.equal(diagnostics.state, "active");
assert.equal(diagnostics.summary.configApplied, true);
assert.equal(diagnostics.summary.connectionCount, 3);
assert.equal(diagnostics.summary.syncableConnectionCount, 3);
assert.equal(diagnostics.summary.diagnostics.errorCount, 0);
}
async function main() {
const fixture = JSON.parse(await readFile(fixturePath, "utf8"));
const box1Port = await freePort();
const box2Port = await freePort();
const patchPort = await freePort();
const tempDir = await mkdtemp(path.join(tmpdir(), "hwlab-patch-panel-runtime-"));
const configPath = path.join(tempDir, "runtime-config.json");
const runtimeConfig = clone(fixture.runtimeConfig);
runtimeConfig.endpointMap = {
res_boxsimu_runtime_1: `http://127.0.0.1:${box1Port}`,
res_boxsimu_runtime_2: `http://127.0.0.1:${box2Port}`
};
await writeFile(configPath, `${JSON.stringify(runtimeConfig, null, 2)}\n`, "utf8");
const box1 = startNode("cmd/hwlab-box-simu/main.mjs", {
PORT: String(box1Port),
HWLAB_BOX_ID: fixture.boxes[0].boxId
});
const box2 = startNode("cmd/hwlab-box-simu/main.mjs", {
PORT: String(box2Port),
HWLAB_BOX_ID: fixture.boxes[1].boxId
});
const patchPanel = startNode("cmd/hwlab-patch-panel/main.mjs", {
PORT: String(patchPort),
HWLAB_WIRING_CONFIG_PATH: configPath
});
try {
const box1Base = `http://127.0.0.1:${box1Port}`;
const box2Base = `http://127.0.0.1:${box2Port}`;
const patchBase = `http://127.0.0.1:${patchPort}`;
await Promise.all([
waitForHttp(`${box1Base}/health/live`, box1),
waitForHttp(`${box2Base}/health/live`, box2),
waitForHttp(`${patchBase}/health/live`, patchPanel)
]);
const diagnostics = await requestJson(`${patchBase}/diagnostics`);
assert.equal(diagnostics.status, 200);
assertPatchPanelDiagnostics(diagnostics.body);
const status = await requestJson(`${patchBase}/status`);
assert.equal(status.status, 200);
assert.equal(status.body.serviceId, "hwlab-patch-panel");
assert.equal(status.body.state, "active");
assert.equal(status.body.activeConnections.length, 3);
const tick = await requestJson(`${patchBase}/sync/tick`, {
method: "POST",
body: {
signals: fixture.signals
}
});
assert.equal(tick.status, 200);
assert.equal(tick.body.accepted, true);
assert.equal(tick.body.deliveryCount, 3);
const targetState = await requestJson(`${box2Base}/status`);
assert.equal(targetState.status, 200);
assert.equal(targetState.body.ports.DI1.value, fixture.expected.ports.DI1);
assert.equal(targetState.body.ports.DI1.propagatedBy, "hwlab-patch-panel");
assert.equal(targetState.body.ports.DI1.source, "patch-panel");
assert.equal(targetState.body.ports.DI1.sourceResourceId, fixture.signals[0].fromResourceId);
assert.equal(targetState.body.ports.AI1.value, fixture.expected.ports.AI1);
assert.equal(targetState.body.ports.AI1.propagatedBy, "hwlab-patch-panel");
assert.equal(targetState.body.ports.AI1.source, "patch-panel");
assert.equal(targetState.body.ports.AI1.sourceResourceId, fixture.signals[1].fromResourceId);
assert.equal(targetState.body.ports.FREQ_IN1.value, fixture.expected.ports.FREQ_IN1);
assert.equal(targetState.body.ports.FREQ_IN1.propagatedBy, "hwlab-patch-panel");
assert.equal(targetState.body.ports.FREQ_IN1.source, "patch-panel");
assert.equal(targetState.body.ports.FREQ_IN1.sourceResourceId, fixture.signals[2].fromResourceId);
const invalidConfig = clone(runtimeConfig);
invalidConfig.wiringConfig = {
...invalidConfig.wiringConfig,
wiringConfigId: "wir_patch_panel_runtime_invalid",
connections: [fixture.invalidWiringProbe]
};
const invalidApply = await requestJson(`${patchBase}/wiring/apply`, {
method: "POST",
body: invalidConfig
});
assert.equal(invalidApply.status, 200);
assert.equal(invalidApply.body.accepted, false);
assert.ok(
invalidApply.body.diagnostics.some((item) => item.code === "source_port_not_output"),
"invalid wiring must report source_port_not_output"
);
console.log("patch-panel runtime smoke passed");
console.log("wiring: DO1->DI1, AO1->AI1, and FREQ_OUT1->FREQ_IN1 synchronized through hwlab-patch-panel");
console.log("diagnostics: invalid DI1->DO1 wiring reported source_port_not_output");
} finally {
await Promise.all([stopProcess(patchPanel), stopProcess(box1), stopProcess(box2)]);
await rm(tempDir, { recursive: true, force: true });
}
}
try {
await main();
} catch (error) {
console.error(`[patch-panel-runtime-smoke] ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}