414 lines
13 KiB
JavaScript
414 lines
13 KiB
JavaScript
#!/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),
|
|
HWLAB_CLOUD_DB_URL: "",
|
|
HWLAB_CLOUD_DB_SSL_MODE: ""
|
|
});
|
|
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, "degraded");
|
|
assert.equal(health.body.db.connected, false);
|
|
assert.equal(health.body.db.status, "blocked");
|
|
assert.equal(health.body.runtime.durable, false);
|
|
assert.deepEqual(health.body.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]);
|
|
assert.equal(health.body.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db");
|
|
assert.equal(health.body.db.secretRefs[0].redacted, true);
|
|
|
|
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, "degraded");
|
|
assert.ok(adapter.body.methods.includes("system.health"));
|
|
assert.ok(adapter.body.methods.includes("gateway.session.register"));
|
|
assert.ok(adapter.body.methods.includes("box.capability.report"));
|
|
assert.ok(adapter.body.methods.includes("hardware.operation.request"));
|
|
assert.ok(adapter.body.methods.includes("audit.event.query"));
|
|
assert.ok(adapter.body.methods.includes("evidence.record.query"));
|
|
|
|
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, "degraded");
|
|
assert.equal(rpcHealth.body.result.serviceId, "hwlab-cloud-api");
|
|
assert.equal(rpcHealth.body.result.db.status, "blocked");
|
|
assert.equal(rpcHealth.body.meta.serviceId, "hwlab-cloud-api");
|
|
|
|
const projectId = "prj_mvp_topology";
|
|
const gatewaySessionId = "gws_m1_runtime";
|
|
const resourceId = "res_m1_runtime";
|
|
const capabilityId = "cap_m1_runtime";
|
|
|
|
const rpcGateway = await requestJson(`${baseUrl}/rpc`, {
|
|
method: "POST",
|
|
body: JSON.stringify(
|
|
rpcEnvelope({
|
|
id: "req_m1_gateway",
|
|
method: "gateway.session.register",
|
|
params: {
|
|
projectId,
|
|
gatewaySessionId,
|
|
gatewayId: "gtw_m1_runtime",
|
|
serviceId: "hwlab-gateway-simu",
|
|
endpoint: `http://127.0.0.1:${port}/gateway`
|
|
}
|
|
})
|
|
)
|
|
});
|
|
assert.equal(rpcGateway.response.status, 200);
|
|
assert.equal(rpcGateway.body.result.registered, true);
|
|
|
|
const rpcBox = await requestJson(`${baseUrl}/rpc`, {
|
|
method: "POST",
|
|
body: JSON.stringify(
|
|
rpcEnvelope({
|
|
id: "req_m1_box",
|
|
method: "box.resource.register",
|
|
params: {
|
|
projectId,
|
|
gatewaySessionId,
|
|
resourceId,
|
|
boxId: "box_m1_runtime",
|
|
resourceType: "board"
|
|
}
|
|
})
|
|
)
|
|
});
|
|
assert.equal(rpcBox.response.status, 200);
|
|
assert.equal(rpcBox.body.result.registered, true);
|
|
|
|
const rpcCapability = await requestJson(`${baseUrl}/rpc`, {
|
|
method: "POST",
|
|
body: JSON.stringify(
|
|
rpcEnvelope({
|
|
id: "req_m1_capability",
|
|
method: "box.capability.report",
|
|
params: {
|
|
capabilityId,
|
|
resourceId,
|
|
projectId,
|
|
name: "shell.exec",
|
|
direction: "bidirectional",
|
|
valueType: "object",
|
|
mutatesState: true
|
|
}
|
|
})
|
|
)
|
|
});
|
|
assert.equal(rpcCapability.response.status, 200);
|
|
assert.equal(rpcCapability.body.result.reported, true);
|
|
|
|
const rpcHardware = await requestJson(`${baseUrl}/rpc`, {
|
|
method: "POST",
|
|
body: JSON.stringify(
|
|
rpcEnvelope({
|
|
id: "req_m1_hardware",
|
|
method: "hardware.operation.request",
|
|
params: {
|
|
projectId,
|
|
gatewaySessionId,
|
|
resourceId,
|
|
capabilityId,
|
|
input: {
|
|
command: "echo m1"
|
|
}
|
|
}
|
|
})
|
|
)
|
|
});
|
|
assert.equal(rpcHardware.response.status, 200);
|
|
assert.equal(rpcHardware.body.result.accepted, true);
|
|
assert.equal(rpcHardware.body.result.status, "accepted");
|
|
assert.equal(rpcHardware.body.result.auditEvent.serviceId, "hwlab-cloud-api");
|
|
assert.equal(rpcHardware.body.result.persistence.durable, false);
|
|
|
|
logOk("cloud-api health/live/rpc runtime");
|
|
}
|
|
|
|
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 runtime");
|
|
}
|
|
|
|
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);
|
|
const output = JSON.parse(stdout);
|
|
assert.equal(output.command, "hwlab-cli test e2e --env dev --mvp --dry-run");
|
|
assert.equal(output.mode, "dry-run");
|
|
assert.equal(output.environment, "dev");
|
|
assert.equal(output.endpoint, DEV_ENDPOINT);
|
|
assert.equal(output.safety.allowNetwork, false);
|
|
assert.equal(output.contractStatus.artifacts.services.includes("hwlab-cloud-web"), true);
|
|
assert.equal(output.contractStatus.artifacts.services.includes("hwlab-cloud-api"), true);
|
|
assert.equal(output.contractStatus.topology.gatewaySessions, 2);
|
|
assert.equal(output.contractStatus.topology.boxResources, 2);
|
|
assert.equal(output.contractStatus.agent.agentSessionId, "agt_m5-0001");
|
|
assert.equal(output.contractStatus.evidence.records.length, 2);
|
|
assert.match(output.statement, /no DEV\/PROD changes/);
|
|
|
|
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));
|
|
}
|