#!/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; }