test: add m1 local contract smoke
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs";
|
||||
import { runCli } from "../tools/hwlab-cli/lib/cli.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const children = [];
|
||||
|
||||
function logOk(name) {
|
||||
process.stdout.write(`[m1-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 becoming ready\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 rpcEnvelope({ id, method, params = {} }) {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
meta: {
|
||||
traceId: `trc_${id}`,
|
||||
serviceId: "hwlab-cli",
|
||||
environment: ENVIRONMENT_DEV
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function smokeCloudApi() {
|
||||
const port = await freePort();
|
||||
const service = startNode("cmd/hwlab-cloud-api/main.mjs", {
|
||||
HWLAB_CLOUD_API_HOST: "127.0.0.1",
|
||||
HWLAB_CLOUD_API_PORT: String(port)
|
||||
});
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
await waitForHttp(`${baseUrl}/live`, service);
|
||||
|
||||
const health = await requestJson(`${baseUrl}/health`);
|
||||
assert.equal(health.response.status, 200);
|
||||
assert.equal(health.body.serviceId, "hwlab-cloud-api");
|
||||
assert.equal(health.body.environment, ENVIRONMENT_DEV);
|
||||
assert.equal(health.body.status, "ok");
|
||||
assert.deepEqual(health.body.db, {
|
||||
connected: false,
|
||||
mode: "not_configured"
|
||||
});
|
||||
|
||||
const live = await requestJson(`${baseUrl}/live`);
|
||||
assert.equal(live.response.status, 200);
|
||||
assert.equal(live.body.status, "live");
|
||||
|
||||
const adapter = await requestJson(`${baseUrl}/v1`);
|
||||
assert.equal(adapter.response.status, 200);
|
||||
assert.equal(adapter.body.status, "skeleton");
|
||||
assert.ok(adapter.body.methods.includes("system.health"));
|
||||
assert.ok(adapter.body.methods.includes("hardware.operation.request"));
|
||||
|
||||
const rpcHealth = await requestJson(`${baseUrl}/rpc`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(rpcEnvelope({ id: "req_m1_health", method: "system.health" }))
|
||||
});
|
||||
assert.equal(rpcHealth.response.status, 200);
|
||||
assert.equal(rpcHealth.body.jsonrpc, "2.0");
|
||||
assert.equal(rpcHealth.body.result.status, "ok");
|
||||
assert.equal(rpcHealth.body.result.serviceId, "hwlab-cloud-api");
|
||||
assert.equal(rpcHealth.body.meta.serviceId, "hwlab-cloud-api");
|
||||
|
||||
const rpcHardware = await requestJson(`${baseUrl}/rpc`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(
|
||||
rpcEnvelope({
|
||||
id: "req_m1_hardware",
|
||||
method: "hardware.operation.request",
|
||||
params: {
|
||||
projectId: "prj_mvp_topology"
|
||||
}
|
||||
})
|
||||
)
|
||||
});
|
||||
assert.equal(rpcHardware.response.status, 200);
|
||||
assert.equal(rpcHardware.body.result.accepted, false);
|
||||
assert.equal(rpcHardware.body.result.status, "skeleton");
|
||||
assert.equal(rpcHardware.body.result.audit.source.serviceId, "hwlab-cloud-api");
|
||||
|
||||
logOk("cloud-api health/live/rpc skeleton");
|
||||
}
|
||||
|
||||
async function smokePatchPanelAndSimulators() {
|
||||
const gatewayPort = await freePort();
|
||||
const boxPort = await freePort();
|
||||
const patchPort = await freePort();
|
||||
|
||||
const gateway = startNode("cmd/hwlab-gateway-simu/main.mjs", {
|
||||
PORT: String(gatewayPort),
|
||||
HWLAB_GATEWAY_ID: "gwsimu_m1",
|
||||
HWLAB_BOX_RESOURCES: "res_boxsim_alpha,res_boxsim_beta"
|
||||
});
|
||||
const box = startNode("cmd/hwlab-box-simu/main.mjs", {
|
||||
PORT: String(boxPort),
|
||||
HWLAB_BOX_ID: "boxsim_alpha"
|
||||
});
|
||||
const patchPanel = startNode("cmd/hwlab-patch-panel/main.mjs", {
|
||||
PORT: String(patchPort)
|
||||
});
|
||||
|
||||
const gatewayBase = `http://127.0.0.1:${gatewayPort}`;
|
||||
const boxBase = `http://127.0.0.1:${boxPort}`;
|
||||
const patchBase = `http://127.0.0.1:${patchPort}`;
|
||||
|
||||
await Promise.all([
|
||||
waitForHttp(`${gatewayBase}/health/live`, gateway),
|
||||
waitForHttp(`${boxBase}/health/live`, box),
|
||||
waitForHttp(`${patchBase}/health/live`, patchPanel)
|
||||
]);
|
||||
|
||||
const gatewayStatus = await requestJson(`${gatewayBase}/status`);
|
||||
assert.equal(gatewayStatus.response.status, 200);
|
||||
assert.equal(gatewayStatus.body.serviceId, "hwlab-gateway-simu");
|
||||
assert.equal(gatewayStatus.body.gatewayId, "gwsimu_m1");
|
||||
assert.equal(gatewayStatus.body.live, true);
|
||||
assert.deepEqual(gatewayStatus.body.boxes, ["res_boxsim_alpha", "res_boxsim_beta"]);
|
||||
|
||||
const boxStatus = await requestJson(`${boxBase}/status`);
|
||||
assert.equal(boxStatus.response.status, 200);
|
||||
assert.equal(boxStatus.body.serviceId, "hwlab-box-simu");
|
||||
assert.equal(boxStatus.body.boxId, "boxsim_alpha");
|
||||
assert.equal(boxStatus.body.live, true);
|
||||
assert.equal(boxStatus.body.constraints.crossDevicePropagation, "patch-panel-only");
|
||||
assert.equal(boxStatus.body.constraints.localLoopbackEnabled, false);
|
||||
assert.ok(boxStatus.body.ports.uart0);
|
||||
|
||||
const writePort = await requestJson(`${boxBase}/ports/write`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
port: "uart0",
|
||||
value: "m1-smoke",
|
||||
source: "local"
|
||||
})
|
||||
});
|
||||
assert.equal(writePort.response.status, 200);
|
||||
assert.equal(writePort.body.accepted, true);
|
||||
assert.equal(writePort.body.crossDevicePropagation, "patch-panel-only");
|
||||
|
||||
const patchStatus = await requestJson(`${patchBase}/status`);
|
||||
assert.equal(patchStatus.response.status, 200);
|
||||
assert.equal(patchStatus.body.serviceId, "hwlab-patch-panel");
|
||||
assert.equal(patchStatus.body.state, "active");
|
||||
assert.equal(patchStatus.body.metadata.propagation, "patch-panel-only");
|
||||
assert.equal(patchStatus.body.activeConnections.length, 2);
|
||||
|
||||
const routed = await requestJson(`${patchBase}/signals/route`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
fromResourceId: "res_boxsim_alpha",
|
||||
fromPort: "uart0",
|
||||
value: "m1-smoke"
|
||||
})
|
||||
});
|
||||
assert.equal(routed.response.status, 200);
|
||||
assert.equal(routed.body.accepted, true);
|
||||
assert.equal(routed.body.propagatedBy, "hwlab-patch-panel");
|
||||
assert.equal(routed.body.deliveryCount, 1);
|
||||
assert.deepEqual(routed.body.deliveries[0], {
|
||||
resourceId: "res_boxsim_beta",
|
||||
port: "uart0",
|
||||
value: "m1-smoke",
|
||||
sourceResourceId: "res_boxsim_alpha",
|
||||
sourcePort: "uart0",
|
||||
observedAt: routed.body.deliveries[0].observedAt
|
||||
});
|
||||
|
||||
logOk("patch-panel/sim status and route skeleton");
|
||||
}
|
||||
|
||||
async function smokeCliDryRunFixture() {
|
||||
const runtime = JSON.parse(await readFile(path.join(repoRoot, "fixtures/mvp/runtime.json"), "utf8"));
|
||||
assert.equal(runtime.endpoints.dev, DEV_ENDPOINT);
|
||||
assert.ok(runtime.serviceRoute.includes("cloud-web/cloud-api"));
|
||||
assert.ok(runtime.loops.some((loop) => loop.name === "hardware trusted closed loop"));
|
||||
assert.ok(runtime.evidence.some((record) => record.evidenceId === "evidence_mvp-runtime-plan"));
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const exitCode = await runCli(["test", "e2e", "--env", "dev", "--mvp", "--dry-run"], {
|
||||
cwd: repoRoot,
|
||||
stdout: {
|
||||
write(chunk) {
|
||||
stdout += chunk;
|
||||
}
|
||||
},
|
||||
stderr: {
|
||||
write(chunk) {
|
||||
stderr += chunk;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(exitCode, 0, stderr);
|
||||
assert.match(stdout, /hwlab-cli test e2e --env dev --mvp --dry-run/);
|
||||
assert.match(stdout, /cloud-web\/cloud-api/);
|
||||
assert.match(stdout, /hardware trusted closed loop/);
|
||||
assert.match(stdout, /evidence_mvp-runtime-plan/);
|
||||
assert.match(stdout, /dry-run only: no DEV\/PROD changes were made/);
|
||||
|
||||
logOk("CLI dry-run fixture parse");
|
||||
}
|
||||
|
||||
try {
|
||||
await smokeCloudApi();
|
||||
await smokePatchPanelAndSimulators();
|
||||
await smokeCliDryRunFixture();
|
||||
process.stdout.write("[m1-smoke] passed\n");
|
||||
} finally {
|
||||
await Promise.allSettled(children.map(stopNode));
|
||||
}
|
||||
Reference in New Issue
Block a user