test: add m1 local contract smoke
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# M1 Local Contract Smoke
|
||||
|
||||
The M1 local smoke is a fast contract check for the current HWLAB MVP
|
||||
skeleton. It runs only on the local machine, uses short-lived localhost
|
||||
processes, and does not deploy or call the frozen DEV endpoint.
|
||||
|
||||
Run it from the repository root:
|
||||
|
||||
```sh
|
||||
node scripts/m1-contract-smoke.mjs
|
||||
```
|
||||
|
||||
The equivalent npm entry point is:
|
||||
|
||||
```sh
|
||||
npm run m1:smoke
|
||||
```
|
||||
|
||||
## What It Checks
|
||||
|
||||
- Starts `hwlab-cloud-api` on `127.0.0.1` with an ephemeral port and checks
|
||||
`/health`, `/live`, `GET /v1`, and `POST /rpc`.
|
||||
- Verifies the cloud-api skeleton returns `system.health`, describes the REST
|
||||
and JSON-RPC adapter surface, and rejects `hardware.operation.request` as a
|
||||
known skeleton response instead of dispatching real hardware work.
|
||||
- Starts `hwlab-gateway-simu`, `hwlab-box-simu`, and `hwlab-patch-panel` on
|
||||
ephemeral localhost ports.
|
||||
- Checks simulator health and status payloads, confirms cross-device
|
||||
propagation is `patch-panel-only`, writes a local box port value, and routes a
|
||||
signal through the patch panel fixture.
|
||||
- Parses `fixtures/mvp/runtime.json` through the CLI dry-run path and verifies
|
||||
the dry-run output names the MVP route, closed loops, evidence fixture, and
|
||||
the no-DEV/PROD-change disclaimer.
|
||||
|
||||
## Boundary
|
||||
|
||||
This smoke is not the future DEV e2e acceptance test. It intentionally does not:
|
||||
|
||||
- deploy to DEV or PROD;
|
||||
- call `http://74.48.78.17:6667`;
|
||||
- run browser e2e;
|
||||
- read secrets, tokens, kubeconfig, tunnel credentials, or deploy manifests;
|
||||
- validate real gateway, router, frp, edge proxy, agent, worker, or hardware
|
||||
behavior;
|
||||
- replace the L5 deploy manifest work.
|
||||
|
||||
The check is scoped to local M1 contract readiness: existing cloud-api,
|
||||
patch-panel, simulator, and CLI dry-run skeletons can be started or parsed and
|
||||
still agree on the frozen service IDs, DEV environment, JSON-RPC surface, and
|
||||
patch-panel-only topology assumptions.
|
||||
|
||||
## Follow-On DEV e2e
|
||||
|
||||
A later real DEV e2e should run against the deployed DEV route, exercise the
|
||||
actual cloud-web/cloud-api, gateway/router/tunnel path, hardware trusted closed
|
||||
loop, agent automation closed loop, evidence persistence, and cleanup behavior.
|
||||
That later test should produce deployment/evidence artifacts and must remain
|
||||
separate from this local smoke harness.
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"validate": "node scripts/validate-contract.mjs",
|
||||
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check scripts/validate-contract.mjs && node scripts/validate-contract.mjs && node --test internal/agent/index.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
|
||||
"m1:smoke": "node scripts/m1-contract-smoke.mjs",
|
||||
"cli:health": "node tools/hwlab-cli/bin/hwlab-cli.mjs health",
|
||||
"cli:dry-run": "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run",
|
||||
"cli:projects": "node tools/hwlab-cli/bin/hwlab-cli.mjs project list",
|
||||
|
||||
@@ -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