664 lines
24 KiB
JavaScript
664 lines
24 KiB
JavaScript
#!/usr/bin/env bun
|
|
import assert from "node:assert/strict";
|
|
import { spawn } from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import net from "node:net";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs";
|
|
import { validateCodeAgentChatSchema } from "../internal/cloud/code-agent-chat.ts";
|
|
import { runCli } from "../tools/hwlab-cli/lib/cli.mjs";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const children = [];
|
|
const bunCommand = resolveBunCommand();
|
|
const CODEX_STDIO_FEASIBILITY_BLOCKERS = new Set([
|
|
"codex_cli_binary_missing",
|
|
"codex_stdio_supervisor_disabled",
|
|
"codex_stdio_egress_boundary"
|
|
]);
|
|
|
|
function logOk(name) {
|
|
process.stdout.write(`[m1-smoke] ok ${name}\n`);
|
|
}
|
|
|
|
function assertCodexStdioFeasibilityBlocker(value, label = "codex stdio blocker") {
|
|
assert.equal(CODEX_STDIO_FEASIBILITY_BLOCKERS.has(value), true, `${label}: ${value}`);
|
|
}
|
|
|
|
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 command = relativeScript.endsWith(".ts") ? bunCommand : process.execPath;
|
|
const child = spawn(command, [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;
|
|
}
|
|
|
|
function resolveBunCommand() {
|
|
const candidates = [
|
|
process.env.HWLAB_BUN_COMMAND,
|
|
process.env.HOME ? path.join(process.env.HOME, ".bun/bin/bun") : null,
|
|
"bun"
|
|
].filter(Boolean);
|
|
return candidates.find((candidate) => !candidate.includes("/") || existsSync(candidate)) || "bun";
|
|
}
|
|
|
|
function runNodeJson(relativeScript, args = [], env = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(process.execPath, [relativeScript, ...args], {
|
|
cwd: repoRoot,
|
|
env: {
|
|
...process.env,
|
|
...env
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
|
|
child.stdout.setEncoding("utf8");
|
|
child.stderr.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
child.on("error", reject);
|
|
child.on("close", (code) => {
|
|
if (code !== 0) {
|
|
reject(new Error(`${relativeScript} ${args.join(" ")} exited with ${code}\nstdout:\n${stdout}\nstderr:\n${stderr}`));
|
|
return;
|
|
}
|
|
try {
|
|
resolve(JSON.parse(stdout));
|
|
} catch (error) {
|
|
reject(new Error(`${relativeScript} ${args.join(" ")} did not return JSON: ${error.message}\nstdout:\n${stdout}\nstderr:\n${stderr}`));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
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.ts", {
|
|
HWLAB_CLOUD_API_HOST: "127.0.0.1",
|
|
HWLAB_CLOUD_API_PORT: String(port),
|
|
HWLAB_CLOUD_DB_URL: "",
|
|
HWLAB_CLOUD_DB_SSL_MODE: "",
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-cli",
|
|
HWLAB_CODE_AGENT_MODEL: "gpt-m1-local",
|
|
HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex",
|
|
OPENAI_API_KEY: "",
|
|
PATH: ""
|
|
});
|
|
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.liveConnected, false);
|
|
assert.equal(health.body.db.liveDbEvidence, false);
|
|
assert.equal(health.body.db.connectionAttempted, false);
|
|
assert.equal(health.body.db.connectionResult, "not_attempted_missing_env");
|
|
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);
|
|
assert.equal(health.body.codeAgent.status, "partial");
|
|
assert.match(health.body.codeAgent.blocker, /Codex stdio|long-lived|Codex CLI command/u);
|
|
assertCodexStdioFeasibilityBlocker(health.body.codeAgent.reason, "health codeAgent reason");
|
|
assert.equal(health.body.readiness.provider.status, "blocked");
|
|
assert.equal(health.body.readiness.provider.blocker, "provider_config_blocked");
|
|
assert.equal(health.body.readiness.sessionRunner.status, "read_only_long_lived_ready");
|
|
assert.equal(health.body.readiness.sessionRunner.ready, true);
|
|
assert.equal(health.body.readiness.codexStdio.status, "blocked");
|
|
assertCodexStdioFeasibilityBlocker(health.body.readiness.codexStdio.blocker, "health readiness codexStdio blocker");
|
|
assert.equal(health.body.readiness.codeAgent.currentBlockers.includes("provider_config_blocked"), true);
|
|
assert.equal(health.body.readiness.codeAgent.currentBlockers.includes(health.body.readiness.codexStdio.blocker), true);
|
|
assert.ok(health.body.codeAgent.missingEnv.includes("OPENAI_API_KEY"));
|
|
assert.equal(health.body.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
|
assert.equal(health.body.codeAgent.secretRefs[0].secretKey, "openai-api-key");
|
|
assert.equal(health.body.codeAgent.secretRefs[0].redacted, true);
|
|
assert.equal(JSON.stringify(health.body.codeAgent).includes("sk-"), false);
|
|
|
|
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"));
|
|
assert.equal(adapter.body.codeAgent.endpoint, "POST /v1/agent/chat");
|
|
assert.equal(adapter.body.codeAgent.status, "partial");
|
|
assert.match(adapter.body.codeAgent.blocker, /Codex stdio|long-lived|Codex CLI command/u);
|
|
assert.equal(adapter.body.codeAgent.provider, "codex-cli");
|
|
assert.equal(adapter.body.codeAgent.model, "gpt-m1-local");
|
|
assert.equal(adapter.body.codeAgent.backend, "hwlab-cloud-api/codex-cli");
|
|
assert.equal(adapter.body.codeAgent.ready, false);
|
|
assert.equal(adapter.body.readiness.provider.status, "blocked");
|
|
assert.equal(adapter.body.readiness.sessionRunner.status, "read_only_long_lived_ready");
|
|
assert.equal(adapter.body.readiness.codexStdio.status, "blocked");
|
|
assert.ok(adapter.body.codeAgent.missingEnv.includes("OPENAI_API_KEY"));
|
|
assert.equal(adapter.body.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
|
assert.equal(adapter.body.codeAgent.secretRefs[0].secretKey, "openai-api-key");
|
|
assert.equal(adapter.body.codeAgent.secretRefs[0].redacted, true);
|
|
assert.match(adapter.body.codeAgent.summary, /hwlab-code-agent-provider\/openai-api-key/u);
|
|
assert.equal(JSON.stringify(adapter.body.codeAgent).includes("sk-"), false);
|
|
|
|
const agentChat = await requestJson(`${baseUrl}/v1/agent/chat`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
conversationId: "cnv_m1_local_chat",
|
|
traceId: "trc_m1_local_chat",
|
|
projectId: "prj_m1_local_contract",
|
|
message: "请用一句话说明本地 SOURCE/LOCAL 合同。"
|
|
})
|
|
});
|
|
assert.equal(agentChat.response.status, 200);
|
|
validateCodeAgentChatSchema(agentChat.body);
|
|
assert.equal(agentChat.body.conversationId, "cnv_m1_local_chat");
|
|
assert.equal(agentChat.body.sessionId, "cnv_m1_local_chat");
|
|
assert.match(agentChat.body.messageId, /^msg_/);
|
|
assert.equal(agentChat.body.status, "failed");
|
|
assert.equal(agentChat.body.traceId, "trc_m1_local_chat");
|
|
assert.equal(agentChat.body.projectId, "prj_m1_local_contract");
|
|
assert.equal(agentChat.body.provider, "codex-cli");
|
|
assert.equal(agentChat.body.model, "gpt-m1-local");
|
|
assert.equal(agentChat.body.backend, "hwlab-cloud-api/codex-cli");
|
|
assert.equal(agentChat.body.error.code, "codex_cli_binary_missing");
|
|
assert.match(agentChat.body.error.message, /Codex CLI command is not available/);
|
|
assert.deepEqual(agentChat.body.error.missingCommands, ["/tmp/hwlab-missing-codex"]);
|
|
assert.ok(agentChat.body.error.missingEnv.includes("OPENAI_API_KEY"));
|
|
assert.equal(agentChat.body.availability.status, "partial");
|
|
assert.match(agentChat.body.availability.blocker, /Codex stdio|long-lived|Codex CLI command/u);
|
|
assertCodexStdioFeasibilityBlocker(agentChat.body.availability.reason, "agent chat availability reason");
|
|
assert.equal(agentChat.body.availability.fallback.status, "blocked");
|
|
assert.equal(agentChat.body.availability.fallback.reason, "provider_unavailable");
|
|
assert.equal(agentChat.body.availability.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
|
assert.equal(agentChat.body.availability.secretRefs[0].secretKey, "openai-api-key");
|
|
assert.equal(agentChat.body.availability.secretRefs[0].redacted, true);
|
|
assert.match(agentChat.body.availability.summary, /hwlab-code-agent-provider\/openai-api-key/u);
|
|
assert.equal(JSON.stringify(agentChat.body).includes("sk-"), false);
|
|
assert.equal(Object.hasOwn(agentChat.body, "reply"), false);
|
|
|
|
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),
|
|
HWLAB_WIRING_CONFIG_PATH: path.join(repoRoot, "fixtures/mvp-topology/topology.json")
|
|
});
|
|
|
|
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");
|
|
}
|
|
|
|
async function smokeAgentMgrLocalContract() {
|
|
const stateDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-m1-agent-mgr-"));
|
|
const agentSessionId = "agt_m1_local_contract";
|
|
const agentMgrPort = await freePort();
|
|
let agentMgr;
|
|
|
|
try {
|
|
agentMgr = startNode("cmd/hwlab-agent-mgr/main.mjs", {
|
|
PORT: String(agentMgrPort),
|
|
HWLAB_AGENT_RUNTIME_STATE_DIR: stateDir,
|
|
HWLAB_ENVIRONMENT: ENVIRONMENT_DEV
|
|
});
|
|
const baseUrl = `http://127.0.0.1:${agentMgrPort}`;
|
|
await waitForHttp(`${baseUrl}/health/live`, agentMgr);
|
|
|
|
const healthLive = await requestJson(`${baseUrl}/health/live`);
|
|
assert.equal(healthLive.response.status, 200);
|
|
assert.equal(healthLive.body.serviceId, "hwlab-agent-mgr");
|
|
assert.equal(healthLive.body.ok, true);
|
|
assert.equal(healthLive.body.environment, ENVIRONMENT_DEV);
|
|
assert.equal(healthLive.body.health.serviceId, "hwlab-agent-mgr");
|
|
assert.equal(healthLive.body.health.status, "degraded");
|
|
assert.equal(healthLive.body.health.runtimeMode, "local-dry-run");
|
|
assert.ok(healthLive.body.health.skills.missing.includes("manager.skillCommitId"));
|
|
assert.ok(healthLive.body.health.skills.missing.includes("manager.skillVersion"));
|
|
assert.equal(healthLive.body.skillsManifest, null);
|
|
|
|
const health = await runNodeJson("cmd/hwlab-agent-mgr/main.mjs", [
|
|
"health",
|
|
"--state-dir",
|
|
stateDir,
|
|
"--skill-commit-id",
|
|
"local-contract",
|
|
"--skill-version",
|
|
"m1-local"
|
|
]);
|
|
assert.equal(health.serviceId, "hwlab-agent-mgr");
|
|
assert.equal(health.ok, true);
|
|
assert.equal(health.health.serviceId, "hwlab-agent-mgr");
|
|
assert.equal(health.health.status, "ok");
|
|
assert.equal(health.health.environment, ENVIRONMENT_DEV);
|
|
assert.equal(health.health.runtimeMode, "local-dry-run");
|
|
assert.equal(health.health.workspaceCleaned, false);
|
|
assert.equal(health.health.skills.manager.status, "ready");
|
|
assert.equal(health.skillsManifest.services[0].serviceId, "hwlab-agent-skills");
|
|
assert.equal(health.skillsManifest.environment, ENVIRONMENT_DEV);
|
|
|
|
const created = await runNodeJson("cmd/hwlab-agent-mgr/main.mjs", [
|
|
"create",
|
|
"--state-dir",
|
|
stateDir,
|
|
"--agent-session-id",
|
|
agentSessionId,
|
|
"--project-id",
|
|
"prj_m1_local_contract",
|
|
"--goal",
|
|
"verify M1 local agent manager contract",
|
|
"--prompt",
|
|
"local contract sample",
|
|
"--skill-commit-id",
|
|
"local-contract",
|
|
"--skill-version",
|
|
"m1-local"
|
|
]);
|
|
assert.equal(created.ok, true);
|
|
assert.equal(created.serviceId, "hwlab-agent-mgr");
|
|
assert.equal(created.agentSessionId, agentSessionId);
|
|
assert.equal(created.status, "ok");
|
|
assert.equal(created.environment, ENVIRONMENT_DEV);
|
|
assert.equal(created.runtimeMode, "local-dry-run");
|
|
assert.equal(created.agentSession.serviceId, "hwlab-agent-mgr");
|
|
assert.equal(created.agentSession.projectId, "prj_m1_local_contract");
|
|
assert.equal(created.task.status, "queued");
|
|
assert.equal(created.traceCount, 1);
|
|
assert.equal(created.evidenceCount, 0);
|
|
|
|
const status = await runNodeJson("cmd/hwlab-agent-mgr/main.mjs", [
|
|
"status",
|
|
"--state-dir",
|
|
stateDir,
|
|
"--agent-session-id",
|
|
agentSessionId
|
|
]);
|
|
assert.equal(status.ok, true);
|
|
assert.equal(status.serviceId, "hwlab-agent-mgr");
|
|
assert.equal(status.agentSessionId, agentSessionId);
|
|
assert.equal(status.status, "ok");
|
|
assert.equal(status.task.taskId, `tsk_${agentSessionId}`);
|
|
|
|
const cleanup = await runNodeJson("cmd/hwlab-agent-mgr/main.mjs", [
|
|
"cleanup",
|
|
"--state-dir",
|
|
stateDir,
|
|
"--agent-session-id",
|
|
agentSessionId
|
|
]);
|
|
assert.equal(cleanup.ok, true);
|
|
assert.equal(cleanup.serviceId, "hwlab-agent-mgr");
|
|
assert.equal(cleanup.agentSessionId, agentSessionId);
|
|
assert.equal(cleanup.status, "ok");
|
|
assert.equal(cleanup.cleanup.completed, true);
|
|
assert.equal(cleanup.workspace.existsAfterCleanup, false);
|
|
|
|
logOk("agent-mgr health/sample local contract");
|
|
} finally {
|
|
if (agentMgr) {
|
|
await stopNode(agentMgr);
|
|
}
|
|
await rm(stateDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
try {
|
|
await smokeCloudApi();
|
|
await smokePatchPanelAndSimulators();
|
|
await smokeAgentMgrLocalContract();
|
|
await smokeCliDryRunFixture();
|
|
process.stdout.write("[m1-smoke] passed\n");
|
|
} finally {
|
|
await Promise.allSettled(children.map(stopNode));
|
|
}
|