304 lines
15 KiB
TypeScript
304 lines
15 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { test } from "bun:test";
|
|
|
|
import { runHwpodCli } from "../src/hwpod-harness-lib.ts";
|
|
import { createHwpodSpecRegistry, type HwpodRuntimeSpecRecord, type HwpodRuntimeSpecStore } from "../../internal/hwpod/spec-registry.ts";
|
|
|
|
test("hwpod-cli help exposes workspace cat and apply-patch stdin usage", async () => {
|
|
const result = await runHwpodCli(["help"], { now: () => "2026-06-06T00:00:00.000Z" });
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.payload.action, "hwpod-cli.help");
|
|
assert.equal(result.payload.usage.some((item: string) => /workspace cat/u.test(item)), true);
|
|
assert.equal(result.payload.usage.some((item: string) => /workspace apply-patch/u.test(item)), true);
|
|
assert.match(result.payload.applyPatchExample, /\*\*\* Begin Patch/u);
|
|
});
|
|
|
|
test("hwpod-cli workspace apply-patch passes stdin patch to hwpod-node plan", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwpod-cli-test-"));
|
|
const specPath = path.join(root, "hwpod-spec.yaml");
|
|
const patch = "*** Begin Patch\n*** Update File: app.c\n@@\n old\n+new\n*** End Patch\n";
|
|
try {
|
|
await writeFile(specPath, hwpodSpecText(), "utf8");
|
|
const result = await runHwpodCli(["workspace", "apply-patch", "--spec", specPath, "--reason", "test", "--dry-run"], {
|
|
stdinText: patch,
|
|
now: () => "2026-06-06T00:00:00.000Z"
|
|
});
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.payload.action, "hwpod-cli.plan");
|
|
assert.equal(result.payload.intent, "workspace.apply-patch");
|
|
assert.equal(result.payload.plan.ops[0].op, "workspace.apply-patch");
|
|
assert.equal(result.payload.plan.ops[0].args.patch, patch);
|
|
assert.equal(result.payload.plan.ops[0].args.reason, "test");
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("hwpod-cli workspace apply-patch accepts --patch-content alias", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwpod-cli-test-patch-content-"));
|
|
const specPath = path.join(root, "hwpod-spec.yaml");
|
|
const patch = "*** Begin Patch\n*** Update File: app.c\n@@\n-old\n+new\n*** End Patch\n";
|
|
try {
|
|
await writeFile(specPath, hwpodSpecText(), "utf8");
|
|
const result = await runHwpodCli(["workspace", "apply-patch", "--spec", specPath, "--patch-content", patch, "--dry-run"], {
|
|
now: () => "2026-06-06T00:00:00.000Z"
|
|
});
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.payload.intent, "workspace.apply-patch");
|
|
assert.equal(result.payload.plan.ops[0].args.patch, patch);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("hwlab-cli hwpod L0 executes workspace edits and a bounded build helper", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwpod-cli-l0-"));
|
|
const specPath = path.join(root, "hwpod-spec.yaml");
|
|
const sourcePath = path.join(root, "app.c");
|
|
const artifactPath = path.join(root, "firmware.hex");
|
|
const patch = "*** Begin Patch\n*** Update File: app.c\n@@\n-old\n+new\n*** End Patch\n";
|
|
try {
|
|
await writeFile(specPath, hwpodSpecText(root, "node build.mjs"), "utf8");
|
|
await writeFile(sourcePath, "old\n", "utf8");
|
|
await writeFile(path.join(root, "build.mjs"), "import { writeFileSync } from 'node:fs'; writeFileSync('firmware.hex', ':00000001FF\\n'); console.log('build completed');\n", "utf8");
|
|
|
|
const edited = await runHwpodCli(["workspace", "apply-patch", "--local", "--spec", specPath, "--patch-content", patch]);
|
|
assert.equal(edited.exitCode, 0);
|
|
assert.equal(edited.payload.action, "hwpod-cli.local");
|
|
assert.equal(edited.payload.mode, "l0-native-function");
|
|
assert.equal(edited.payload.transport, "native-function");
|
|
assert.equal(await readFile(sourcePath, "utf8"), "new\n");
|
|
|
|
const listed = await runHwpodCli(["workspace", "ls", ".", "--local", "--spec", specPath]);
|
|
assert.equal(listed.payload.body.results[0].output.entries.some((item: any) => item.name === "app.c"), true);
|
|
|
|
const searched = await runHwpodCli(["workspace", "rg", "new", ".", "--local", "--spec", specPath]);
|
|
assert.equal(searched.payload.body.results[0].output.matchCount, 1);
|
|
|
|
const written = await runHwpodCli(["workspace", "write", "generated.txt", "--content", "alpha\n", "--local", "--spec", specPath]);
|
|
assert.equal(written.exitCode, 0);
|
|
assert.equal(await readFile(path.join(root, "generated.txt"), "utf8"), "alpha\n");
|
|
|
|
const replaced = await runHwpodCli(["workspace", "replace", "app.c", "--find", "new", "--replace", "newer", "--local", "--spec", specPath]);
|
|
assert.equal(replaced.exitCode, 0);
|
|
const inserted = await runHwpodCli(["workspace", "insert-after", "app.c", "--anchor", "newer", "--line", "marker", "--local", "--spec", specPath]);
|
|
assert.equal(inserted.exitCode, 0);
|
|
|
|
const read = await runHwpodCli(["workspace", "cat", "app.c", "--local", "--spec", specPath]);
|
|
assert.equal(read.payload.body.results[0].output.content, "newer\nmarker\n");
|
|
|
|
const child = Bun.spawn([
|
|
process.execPath,
|
|
path.join(process.cwd(), "tools/hwlab-cli/bin/hwlab-cli.ts"),
|
|
"hwpod",
|
|
"build",
|
|
"--local",
|
|
"--spec",
|
|
specPath,
|
|
], { cwd: process.cwd(), stdout: "pipe", stderr: "pipe" });
|
|
const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
assert.equal(exitCode, 0, stderr);
|
|
const built = JSON.parse(stdout);
|
|
assert.equal(built.action, "hwpod-cli.local");
|
|
assert.equal(built.mode, "l0-native-function");
|
|
assert.match(built.body.results[0].output.stdout, /build completed/u);
|
|
assert.equal(await readFile(artifactPath, "utf8"), ":00000001FF\n");
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("HWPOD L0 local mode fails closed without an explicit spec", async () => {
|
|
const result = await runHwpodCli(["workspace", "ls", ".", "--local"]);
|
|
assert.equal(result.exitCode, 1);
|
|
assert.equal(result.payload.error.code, "hwpod_l0_spec_required");
|
|
});
|
|
|
|
test("HWPOD execution modes fail closed when local and over-api are combined", async () => {
|
|
const result = await runHwpodCli(["workspace", "ls", ".", "--local", "--over-api", "--spec", "unused.yaml"]);
|
|
assert.equal(result.exitCode, 1);
|
|
assert.equal(result.payload.error.code, "hwpod_execution_mode_conflict");
|
|
});
|
|
|
|
test("HWPOD L0 and L1 over-api expose the same operation contract", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwpod-cli-compare-"));
|
|
const specPath = path.join(root, "hwpod-spec.yaml");
|
|
const operationId = "hwpod_compare_001";
|
|
let submitted: any = null;
|
|
const fetchImpl = async (input: string | URL | Request, init?: RequestInit) => {
|
|
const url = new URL(String(input));
|
|
if (url.pathname === "/v1/hwpod/operations" && init?.method === "POST") {
|
|
submitted = JSON.parse(String(init.body));
|
|
return Response.json({
|
|
ok: true,
|
|
status: "accepted",
|
|
operationId: submitted.operationId,
|
|
workflowId: `hwpod-operation-${submitted.operationId}`,
|
|
workflowRunId: "run-001",
|
|
authority: "hwpod-temporal",
|
|
valuesPrinted: false
|
|
}, { status: 202 });
|
|
}
|
|
if (url.pathname === `/v1/hwpod/operations/${operationId}` && init?.method === "GET") {
|
|
return Response.json({
|
|
ok: true,
|
|
operationId,
|
|
workflowId: `hwpod-operation-${operationId}`,
|
|
workflowRunId: "run-001",
|
|
status: "completed",
|
|
result: { ok: true, status: "completed" },
|
|
valuesPrinted: false
|
|
});
|
|
}
|
|
return Response.json({ ok: false, error: { code: "unexpected_route" } }, { status: 404 });
|
|
};
|
|
const env = { HWLAB_HWPOD_OPERATION_IDENTITY: operationId };
|
|
|
|
try {
|
|
await writeFile(specPath, hwpodSpecText(root), "utf8");
|
|
const local = await runHwpodCli(["workspace", "ls", ".", "--local", "--spec", specPath], { env });
|
|
const overApi = await runHwpodCli(["workspace", "ls", ".", "--over-api", "--spec", specPath, "--api-base-url", "http://hwpod.test"], { env, fetchImpl: fetchImpl as typeof fetch });
|
|
|
|
assert.equal(local.exitCode, 0);
|
|
assert.equal(overApi.exitCode, 0);
|
|
assert.equal(overApi.payload.action, "hwpod-cli.over-api");
|
|
assert.equal(overApi.payload.mode, "l1-native-api");
|
|
assert.equal(overApi.payload.transport, "over-api");
|
|
assert.equal(overApi.payload.httpStatus, 202);
|
|
for (const field of ["operationId", "hwpodId", "nodeId", "intent", "contractVersion", "ops"]) {
|
|
assert.deepEqual(overApi.payload[field], local.payload[field], field);
|
|
}
|
|
assert.equal(submitted.operationId, operationId);
|
|
assert.equal(submitted.plan.planId, operationId);
|
|
assert.equal(submitted.plan.intent, local.payload.intent);
|
|
assert.deepEqual(submitted.plan.ops.map((operation: any) => operation.op), local.payload.ops);
|
|
|
|
const status = await runHwpodCli(["operation", "status", operationId, "--over-api", "--api-base-url", "http://hwpod.test"], { env, fetchImpl: fetchImpl as typeof fetch });
|
|
assert.equal(status.exitCode, 0);
|
|
assert.equal(status.payload.action, "hwpod-cli.operation.status");
|
|
assert.equal(status.payload.mode, "l1-native-api");
|
|
assert.equal(status.payload.operationId, operationId);
|
|
assert.equal(status.payload.body.status, "completed");
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("HWPOD operation status requires explicit over-api mode", async () => {
|
|
const result = await runHwpodCli(["operation", "status", "operation-001"]);
|
|
assert.equal(result.exitCode, 1);
|
|
assert.equal(result.payload.error.code, "hwpod_operation_status_over_api_required");
|
|
});
|
|
|
|
test("HWPOD operation status fails when the Temporal result failed", async () => {
|
|
const operationId = "hwpod_failed_001";
|
|
const fetchImpl = async () => Response.json({
|
|
ok: true,
|
|
operationId,
|
|
status: "completed",
|
|
result: { ok: false, status: "failed", response: { error: { code: "node_unavailable" } } }
|
|
});
|
|
|
|
const result = await runHwpodCli([
|
|
"operation", "status", operationId, "--over-api", "--api-base-url", "http://hwpod.test"
|
|
], { fetchImpl: fetchImpl as typeof fetch });
|
|
|
|
assert.equal(result.exitCode, 1);
|
|
assert.equal(result.payload.ok, false);
|
|
assert.equal(result.payload.status, "failed");
|
|
assert.equal(result.payload.body.result.response.error.code, "node_unavailable");
|
|
});
|
|
|
|
test("HWPOD over-api resolves a registry spec from the API envelope", async () => {
|
|
let submitted: any = null;
|
|
const fetchImpl = async (input: string | URL | Request, init?: RequestInit) => {
|
|
const url = new URL(String(input));
|
|
if (url.pathname === "/v1/hwpod/specs/builtin-hwpod" && init?.method === "GET") {
|
|
return Response.json({
|
|
ok: true,
|
|
status: "completed",
|
|
body: {
|
|
hwpodId: "builtin-hwpod",
|
|
authority: "yaml-first-builtin",
|
|
document: hwpodSpecDocument("builtin-hwpod", "/builtin")
|
|
}
|
|
});
|
|
}
|
|
if (url.pathname === "/v1/hwpod/operations" && init?.method === "POST") {
|
|
submitted = JSON.parse(String(init.body));
|
|
return Response.json({ ok: true, status: "accepted", operationId: submitted.operationId }, { status: 202 });
|
|
}
|
|
return Response.json({ ok: false, error: { code: "unexpected_route" } }, { status: 404 });
|
|
};
|
|
|
|
const result = await runHwpodCli([
|
|
"workspace", "ls", ".", "--hwpod-id", "builtin-hwpod", "--over-api", "--api-base-url", "http://hwpod.test"
|
|
], { fetchImpl: fetchImpl as typeof fetch });
|
|
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.payload.hwpodId, "builtin-hwpod");
|
|
assert.equal(result.payload.specAuthority, "yaml-first-builtin");
|
|
assert.equal(submitted.plan.intent, "workspace.ls");
|
|
assert.equal(submitted.plan.ops[0].args.workspacePath, "/builtin");
|
|
});
|
|
|
|
test("HWPOD L0 CLI completes runtime spec CRUD and rejects built-in mutation", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwpod-spec-cli-test-"));
|
|
const createPath = path.join(root, "create.yaml");
|
|
const updatePath = path.join(root, "update.yaml");
|
|
const frozenPath = path.join(root, "frozen.yaml");
|
|
const env = {
|
|
HWPOD_BUILTIN_SPEC_DOCUMENTS_JSON: JSON.stringify([{ configRef: "config/hwpod.yaml#spec", document: hwpodSpecDocument("builtin-hwpod", "/builtin") }])
|
|
};
|
|
const specRegistry = createHwpodSpecRegistry({ store: memoryStore(), builtIns: JSON.parse(env.HWPOD_BUILTIN_SPEC_DOCUMENTS_JSON) });
|
|
try {
|
|
await writeFile(createPath, hwpodSpecText("/runtime-v1", "", "runtime-hwpod"), "utf8");
|
|
await writeFile(updatePath, hwpodSpecText("/runtime-v2", "", "runtime-hwpod"), "utf8");
|
|
await writeFile(frozenPath, hwpodSpecText("/shadow", "", "builtin-hwpod"), "utf8");
|
|
|
|
assert.equal((await runHwpodCli(["spec", "create", "--file", createPath, "--local"], { env, specRegistry })).payload.body.status, "created");
|
|
const listed = await runHwpodCli(["spec", "list", "--local"], { env, specRegistry });
|
|
assert.equal(listed.exitCode, 0);
|
|
assert.deepEqual(listed.payload.body.specs.map((item: any) => [item.hwpodId, item.authority]), [["builtin-hwpod", "yaml-first-builtin"], ["runtime-hwpod", "runtime"]]);
|
|
assert.equal((await runHwpodCli(["spec", "get", "runtime-hwpod", "--local"], { env, specRegistry })).payload.body.document.spec.workspace.path, "/runtime-v1");
|
|
assert.equal((await runHwpodCli(["spec", "update", "runtime-hwpod", "--file", updatePath, "--local"], { env, specRegistry })).payload.body.status, "updated");
|
|
assert.equal((await runHwpodCli(["spec", "delete", "runtime-hwpod", "--local"], { env, specRegistry })).payload.body.status, "deleted");
|
|
|
|
const frozen = await runHwpodCli(["spec", "create", "--file", frozenPath, "--local"], { env, specRegistry });
|
|
assert.equal(frozen.exitCode, 1);
|
|
assert.equal(frozen.payload.error.code, "hwpod_spec_frozen");
|
|
assert.equal(frozen.payload.error.details.mutation, false);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function hwpodSpecText(workspacePath = "F:\\Work\\HWLAB-CASE-F103", buildCommand = "", name = "test-hwpod") {
|
|
return `kind: Hwpod\nmetadata:\n name: ${name}\nspec:\n nodeBinding:\n nodeId: test-node\n workspace:\n path: ${JSON.stringify(workspacePath)}\n${buildCommand ? ` buildCommand: ${JSON.stringify(buildCommand)}\n` : ""} targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n ioProbe:\n uart:\n port: COM9\n`;
|
|
}
|
|
|
|
function hwpodSpecDocument(name: string, workspacePath: string) { return { kind: "Hwpod", metadata: { name }, spec: { nodeBinding: { nodeId: "test-node" }, workspace: { path: workspacePath }, targetDevice: { board: "test" }, debugProbe: { type: "test" }, ioProbe: { type: "test" } } }; }
|
|
|
|
function memoryStore(): HwpodRuntimeSpecStore {
|
|
const records = new Map<string, HwpodRuntimeSpecRecord>();
|
|
return {
|
|
list: async () => [...records.values()],
|
|
get: async (id) => records.get(id) ?? null,
|
|
create: async (record) => records.has(record.hwpodId) ? false : (records.set(record.hwpodId, structuredClone(record)), true),
|
|
update: async (id, document, updatedAt) => {
|
|
const existing = records.get(id);
|
|
if (!existing) return null;
|
|
const record = { ...existing, document: structuredClone(document), updatedAt };
|
|
records.set(id, record);
|
|
return record;
|
|
},
|
|
delete: async (id) => { const record = records.get(id) ?? null; records.delete(id); return record; },
|
|
};
|
|
}
|