225 lines
13 KiB
TypeScript
225 lines
13 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { test } from "bun:test";
|
|
|
|
import { runHwpodCli, runHwpodCompilerCli, runHwpodCtl } from "./src/hwpod-harness-lib.ts";
|
|
|
|
const NOW = "2026-06-05T00:00:00.000Z";
|
|
|
|
test("hwpod-ctl initializes, validates, edits, and binds workspace-local hwpod-spec", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-ctl-"));
|
|
const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml");
|
|
try {
|
|
const init = await runHwpodCtl([
|
|
"spec",
|
|
"init",
|
|
"--spec",
|
|
specPath,
|
|
"--name",
|
|
"stm32-local",
|
|
"--node",
|
|
"pc-host-1",
|
|
"--workspace",
|
|
"/workspace/fw"
|
|
], { now: () => NOW });
|
|
assert.equal(init.exitCode, 0);
|
|
assert.equal(init.payload.document.kind, "Hwpod");
|
|
assert.equal(init.payload.document.spec.nodeBinding.nodeId, "pc-host-1");
|
|
|
|
const validate = await runHwpodCtl(["spec", "validate", "--spec", specPath], { now: () => NOW });
|
|
assert.equal(validate.exitCode, 0);
|
|
assert.deepEqual(validate.payload.fourElements, ["targetDevice", "workspace", "debugProbe", "ioProbe"]);
|
|
|
|
const set = await runHwpodCtl(["spec", "set", "spec.targetDevice.id", "stm32f103", "--spec", specPath], { now: () => NOW });
|
|
assert.equal(set.exitCode, 0);
|
|
assert.equal(set.payload.document.spec.targetDevice.id, "stm32f103");
|
|
|
|
const bind = await runHwpodCtl(["bind", "--spec", specPath, "--node", "edge-gateway-1"], { now: () => NOW });
|
|
assert.equal(bind.exitCode, 0);
|
|
assert.equal(bind.payload.nodeId, "edge-gateway-1");
|
|
assert.match(await readFile(specPath, "utf8"), /nodeId: edge-gateway-1/u);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
test("hwpod-compiler-cli compiles workspace-local spec into node ops", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-compiler-"));
|
|
const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml");
|
|
try {
|
|
await runHwpodCtl(["spec", "init", "--spec", specPath, "--node", "pc-host-1", "--workspace", "/workspace/fw"], { now: () => NOW });
|
|
const result = await runHwpodCompilerCli([
|
|
"compile",
|
|
"--spec",
|
|
specPath,
|
|
"--intent",
|
|
"workspace.ls",
|
|
"--args",
|
|
"{\"path\":\"src\"}"
|
|
], { now: () => NOW });
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.payload.contractVersion, "hwpod-node-ops-v1");
|
|
assert.equal(result.payload.plan.nodeId, "pc-host-1");
|
|
assert.equal(result.payload.plan.ops.length, 1);
|
|
assert.equal(result.payload.plan.ops[0].op, "workspace.ls");
|
|
assert.equal(result.payload.plan.ops[0].args.path, "src");
|
|
assert.equal(result.payload.plan.ops[0].args.workspacePath, "/workspace/fw");
|
|
|
|
await runHwpodCtl(["spec", "set", "spec.workspace.buildCommand", "printf spec-build", "--spec", specPath], { now: () => NOW });
|
|
const build = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.build"], { now: () => NOW });
|
|
assert.equal(build.exitCode, 0);
|
|
assert.equal(build.payload.plan.hwpodId, "local-hwpod");
|
|
assert.equal(build.payload.plan.ops[0].op, "debug.build");
|
|
assert.equal(build.payload.plan.ops[0].args.command, "printf spec-build");
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("hwpod-compiler-cli generates Keil build and download commands from structured hwpod-spec", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-keil-compiler-"));
|
|
const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml");
|
|
try {
|
|
await runHwpodCtl(["spec", "init", "--spec", specPath, "--node", "node-d601-f103-v2", "--workspace", "F:\\Work\\D601-HWLAB"], { now: () => NOW });
|
|
await runHwpodCtl(["spec", "set", "spec.workspace.toolchain", "keil-mdk", "--spec", specPath], { now: () => NOW });
|
|
await runHwpodCtl(["spec", "set", "spec.workspace.keilProject", "projects/01_baseline/Projects/MDK-ARM/atk_f103.uvprojx", "--spec", specPath], { now: () => NOW });
|
|
await runHwpodCtl(["spec", "set", "spec.workspace.keilTarget", "USART", "--spec", specPath], { now: () => NOW });
|
|
await runHwpodCtl(["spec", "set", "spec.workspace.keilCliPath", "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py", "--spec", specPath], { now: () => NOW });
|
|
await runHwpodCtl(["spec", "set", "spec.debugProbe.type", "daplink", "--spec", specPath], { now: () => NOW });
|
|
await runHwpodCtl(["spec", "set", "spec.debugProbe.adapter", "keil", "--spec", specPath], { now: () => NOW });
|
|
await runHwpodCtl(["spec", "set", "spec.debugProbe.probeUid", "3FD750C63E342E24", "--spec", specPath], { now: () => NOW });
|
|
await runHwpodCtl(["spec", "set", "spec.debugProbe.probeName", "CherryUSB MicroLink CMSIS-DAP", "--spec", specPath], { now: () => NOW });
|
|
|
|
const build = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.build"], { now: () => NOW });
|
|
assert.equal(build.exitCode, 0);
|
|
assert.equal(build.payload.plan.ops[0].op, "cmd.run");
|
|
assert.equal(build.payload.plan.ops[0].args.target, "USART");
|
|
assert.equal(build.payload.plan.ops[0].args.command, "cmd.exe");
|
|
assert.deepEqual(build.payload.plan.ops[0].args.argv.slice(0, 3), ["/d", "/s", "/c"]);
|
|
assert.match(build.payload.plan.ops[0].args.argv[3], /^py -3 C:\\Users\\liang\\\.agents\\skills\\keil\\keil-cli\.py build -p/u);
|
|
assert.match(build.payload.plan.ops[0].args.argv[3], /F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103\.uvprojx/u);
|
|
assert.match(build.payload.plan.ops[0].args.argv[3], / -t USART$/u);
|
|
assert.match(build.payload.plan.ops[0].args.commandLine, /^py -3 C:\\Users\\liang\\\.agents\\skills\\keil\\keil-cli\.py build -p/u);
|
|
assert.match(build.payload.plan.ops[0].args.commandLine, /F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103\.uvprojx/u);
|
|
assert.match(build.payload.plan.ops[0].args.commandLine, / -t USART$/u);
|
|
assert.equal(build.payload.plan.ops[0].args.commandBinding.source, "hwpod-compiler.keil-mdk");
|
|
assert.equal(build.payload.plan.ops[0].args.commandBinding.action, "build");
|
|
|
|
const download = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.download"], { now: () => NOW });
|
|
assert.equal(download.exitCode, 0);
|
|
assert.equal(download.payload.plan.ops[0].op, "debug.download");
|
|
assert.equal(download.payload.plan.ops[0].args.commandBinding.action, "download");
|
|
assert.equal(download.payload.plan.ops[0].args.commandBinding.programBackend, "keil");
|
|
assert.match(download.payload.plan.ops[0].args.command, /project probe-binding set -p/u);
|
|
assert.match(download.payload.plan.ops[0].args.command, / --target USART /u);
|
|
assert.match(download.payload.plan.ops[0].args.command, /--probe-uid 3FD750C63E342E24/u);
|
|
assert.match(download.payload.plan.ops[0].args.command, /--probe-name "CherryUSB MicroLink CMSIS-DAP"/u);
|
|
assert.match(download.payload.plan.ops[0].args.command, / && py -3 .*keil-cli\.py program -p/u);
|
|
assert.match(download.payload.plan.ops[0].args.command, / -m daplink --program-backend keil -u 3FD750C63E342E24 -t USART$/u);
|
|
|
|
await runHwpodCtl(["spec", "set", "spec.debugProbe.downloadCommand", "custom download command", "--spec", specPath], { now: () => NOW });
|
|
const override = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.download"], { now: () => NOW });
|
|
assert.equal(override.exitCode, 0);
|
|
assert.equal(override.payload.plan.ops[0].args.command, "custom download command");
|
|
assert.equal(override.payload.plan.ops[0].args.commandBinding, undefined);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("hwpod-cli dry-run invokes hwpod-compiler-cli and exposes hwpod-node-ops plan", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-cli-"));
|
|
const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml");
|
|
try {
|
|
await runHwpodCtl(["spec", "init", "--spec", specPath, "--node", "pc-host-1"], { now: () => NOW });
|
|
const inspect = await runHwpodCli(["inspect", "--spec", specPath, "--dry-run"], { now: () => NOW });
|
|
assert.equal(inspect.exitCode, 0);
|
|
assert.equal(inspect.payload.dryRun, true);
|
|
assert.equal(inspect.payload.compilerInvocation.compiler, "hwpod-compiler-cli");
|
|
assert.equal(inspect.payload.compilerInvocation.mode, "subprocess");
|
|
assert.equal(inspect.payload.compilerInvocation.action, "hwpod-compiler.compile");
|
|
assert.equal(inspect.payload.plan.source.compiler, "hwpod-compiler-cli");
|
|
assert.deepEqual(inspect.payload.plan.ops.map((op: any) => op.op), ["node.health", "node.inventory"]);
|
|
|
|
const build = await runHwpodCli(["build", "--spec", specPath, "--dry-run", "--target", "Debug", "--command", "printf cli-build", "--reason", "target flow smoke"], { now: () => NOW });
|
|
assert.equal(build.exitCode, 0);
|
|
assert.equal(build.payload.compilerInvocation.mode, "subprocess");
|
|
assert.equal(build.payload.plan.ops[0].op, "debug.build");
|
|
assert.equal(build.payload.plan.ops[0].args.target, "Debug");
|
|
assert.equal(build.payload.plan.ops[0].args.command, "printf cli-build");
|
|
assert.equal(build.payload.plan.ops[0].args.reason, "target flow smoke");
|
|
|
|
const insert = await runHwpodCli(["workspace", "insert-after", "--spec", specPath, "--path", "projects/01_baseline/User/main.c", "--anchor", " while (1) {", "--line", " /* MARKER_OK */", "--expected-sha", "abc123", "--dry-run"], { now: () => NOW });
|
|
assert.equal(insert.exitCode, 0);
|
|
assert.equal(insert.payload.plan.ops[0].op, "workspace.insert-after");
|
|
assert.equal(insert.payload.plan.ops[0].args.anchor, " while (1) {");
|
|
assert.equal(insert.payload.plan.ops[0].args.line, " /* MARKER_OK */");
|
|
assert.equal(insert.payload.plan.ops[0].args.expectedSha, "abc123");
|
|
|
|
const replace = await runHwpodCli(["workspace", "replace", "--spec", specPath, "--path", "projects/01_baseline/User/main.c", "--find", " /* MARKER_OK */", "--replace", "", "--dry-run"], { now: () => NOW });
|
|
assert.equal(replace.exitCode, 0);
|
|
assert.equal(replace.payload.plan.ops[0].op, "workspace.replace");
|
|
assert.equal(replace.payload.plan.ops[0].args.find, " /* MARKER_OK */");
|
|
assert.equal(replace.payload.plan.ops[0].args.replace, "");
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("hwpod-cli submits compiled node ops to hwlab-api when not dry-run", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-cli-submit-"));
|
|
const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml");
|
|
const seen: any[] = [];
|
|
try {
|
|
await runHwpodCtl(["spec", "init", "--spec", specPath, "--node", "pc-host-1"], { now: () => NOW });
|
|
const result = await runHwpodCli(["workspace", "ls", "src", "--spec", specPath], {
|
|
env: {
|
|
HWLAB_RUNTIME_API_URL: "http://cloud.test",
|
|
HWLAB_RUNTIME_NAMESPACE: "hwlab-v02",
|
|
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1",
|
|
HWLAB_API_KEY: "hwl_live_test"
|
|
},
|
|
fetchImpl: async (url, init) => {
|
|
seen.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) });
|
|
return new Response(JSON.stringify({ ok: true, status: "completed", contractVersion: "hwpod-node-ops-v1", results: [{ op: "workspace.ls", ok: true }] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
},
|
|
now: () => NOW
|
|
});
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.payload.compilerInvocation.compiler, "hwpod-compiler-cli");
|
|
assert.equal(result.payload.compilerInvocation.mode, "subprocess");
|
|
assert.equal(seen[0].url, "http://cloud.test/v1/hwpod-node-ops");
|
|
assert.equal(seen[0].init.headers.authorization, "Bearer hwl_live_test");
|
|
assert.equal(seen[0].body.contractVersion, "hwpod-node-ops-v1");
|
|
assert.equal(seen[0].body.source.compiler, "hwpod-compiler-cli");
|
|
assert.equal(seen[0].body.ops[0].op, "workspace.ls");
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("hwpod-cli rejects API key aliases and requires HWLAB_API_KEY", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-cli-api-key-alias-"));
|
|
const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml");
|
|
try {
|
|
await runHwpodCtl(["spec", "init", "--spec", specPath, "--node", "pc-host-1"], { now: () => NOW });
|
|
const result = await runHwpodCli(["workspace", "ls", "src", "--spec", specPath], {
|
|
env: {
|
|
HWLAB_RUNTIME_API_URL: "http://cloud.test",
|
|
HWLAB_BEARER_TOKEN: "hwl_live_legacy_alias"
|
|
},
|
|
fetchImpl: async () => {
|
|
throw new Error("request should not be sent when an API key alias is present");
|
|
},
|
|
now: () => NOW
|
|
});
|
|
assert.equal(result.exitCode, 1);
|
|
assert.equal(result.payload.error.code, "unsupported_api_key_source");
|
|
assert.deepEqual(result.payload.error.details.unsupported, ["HWLAB_BEARER_TOKEN"]);
|
|
assert.equal(JSON.stringify(result.payload).includes("hwl_live_legacy_alias"), false);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|