252 lines
8.3 KiB
JavaScript
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;
|
|
}
|