341 lines
14 KiB
TypeScript
341 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { test } from "bun:test";
|
|
|
|
import { createHwpodNodeServer, executeHwpodNodeOpsPlan, resolveHwpodNodeCommand } from "./src/hwpod-node-lib.ts";
|
|
|
|
test("hwpod-node executes minimal workspace node ops", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-"));
|
|
try {
|
|
await writeFile(path.join(root, "main.c"), "int main(void) { return 0; }\n", "utf8");
|
|
const result = await executeHwpodNodeOpsPlan({
|
|
contractVersion: "hwpod-node-ops-v1",
|
|
planId: "hwpod_plan_test",
|
|
hwpodId: "hwpod-local",
|
|
nodeId: "pc-host-1",
|
|
ops: [
|
|
{ opId: "op_01", op: "node.health", args: { workspacePath: root } },
|
|
{ opId: "op_02", op: "workspace.ls", args: { workspacePath: root, path: "." } },
|
|
{ opId: "op_03", op: "workspace.cat", args: { workspacePath: root, path: "main.c" } }
|
|
]
|
|
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.status, "completed");
|
|
assert.equal(result.results[1].output.entries[0].name, "main.c");
|
|
assert.match(result.results[2].output.content, /int main/u);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
test("hwpod-node HTTP endpoint accepts hwpod-node-ops plans", async () => {
|
|
const server = createHwpodNodeServer({ now: () => "2026-06-05T00:00:00.000Z" });
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${server.address().port}/v1/hwpod-node-ops`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
contractVersion: "hwpod-node-ops-v1",
|
|
planId: "hwpod_plan_http",
|
|
hwpodId: "hwpod-local",
|
|
nodeId: "pc-host-1",
|
|
ops: [{ opId: "op_01", op: "node.version", args: {} }]
|
|
})
|
|
});
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.ok, true);
|
|
assert.equal(payload.specAuthority, "none");
|
|
assert.equal(payload.results[0].op, "node.version");
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error: Error | undefined) => (error ? reject(error) : resolve(undefined))));
|
|
}
|
|
});
|
|
|
|
test("hwpod-node executes debug ops only through explicit node-side command bindings", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-debug-"));
|
|
try {
|
|
const result = await executeHwpodNodeOpsPlan({
|
|
contractVersion: "hwpod-node-ops-v1",
|
|
planId: "hwpod_plan_debug_bound",
|
|
hwpodId: "hwpod-local",
|
|
nodeId: "pc-host-1",
|
|
ops: [{ opId: "op_build", op: "debug.build", args: { workspacePath: root, command: "printf build-ok > build.ok" } }]
|
|
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.results[0].ok, true);
|
|
assert.equal(result.results[0].output.bindingSource, "hwpod-node-ops.command");
|
|
assert.match(await readFile(path.join(root, "build.ok"), "utf8"), /build-ok/u);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("hwpod-node reports cmd.run non-zero exits as failed results", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-cmd-fail-"));
|
|
try {
|
|
const result = await executeHwpodNodeOpsPlan({
|
|
contractVersion: "hwpod-node-ops-v1",
|
|
planId: "hwpod_plan_cmd_fail",
|
|
hwpodId: "hwpod-local",
|
|
nodeId: "pc-host-1",
|
|
ops: [{ opId: "op_cmd", op: "cmd.run", args: { workspacePath: root, command: "sh", argv: ["-c", "echo no >&2; exit 7"] } }]
|
|
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
|
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.results[0].ok, false);
|
|
assert.equal(result.results[0].status, "failed");
|
|
assert.equal(result.results[0].output.exitCode, 7);
|
|
assert.equal(result.results[0].blocker.code, "hwpod_node_command_failed");
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("hwpod-node reports structured UART binding diagnostics", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-uart-"));
|
|
try {
|
|
const result = await executeHwpodNodeOpsPlan({
|
|
contractVersion: "hwpod-node-ops-v1",
|
|
planId: "hwpod_plan_uart_diag",
|
|
hwpodId: "hwpod-local",
|
|
nodeId: "pc-host-1",
|
|
ops: [{
|
|
opId: "op_uart",
|
|
op: "io.uart.read",
|
|
args: {
|
|
workspacePath: root,
|
|
port: "uart1",
|
|
ioProbe: { uart: { id: "uart1", port: "COM9" } }
|
|
}
|
|
}]
|
|
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
|
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.results[0].status, "blocked");
|
|
assert.equal(result.results[0].blocker.code, "hwpod_node_op_not_configured");
|
|
assert.equal(result.results[0].blocker.details.requestedPort, "uart1");
|
|
assert.equal(result.results[0].blocker.details.resolvedPort, "COM9");
|
|
assert.deepEqual(result.results[0].blocker.details.requiredBinding.nodeEnvHints, ["HWPOD_UART_TOOL", "HWPOD_UART_PORT"]);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("hwpod-node resolves Git for Windows when service PATH omits git", async () => {
|
|
const result = await resolveHwpodNodeCommand("git", {
|
|
platform: "win32",
|
|
env: {},
|
|
fileExists: async (candidate: string) => candidate === "C:\\Program Files\\Git\\cmd\\git.exe"
|
|
});
|
|
|
|
assert.equal(result.command, "C:\\Program Files\\Git\\cmd\\git.exe");
|
|
assert.equal(result.resolved, true);
|
|
assert.equal(result.source, "windows-well-known-tool");
|
|
});
|
|
|
|
test("hwpod-node keeps explicit executable paths unchanged", async () => {
|
|
const result = await resolveHwpodNodeCommand("C:\\Tools\\Git\\cmd\\git.exe", {
|
|
platform: "win32",
|
|
env: {},
|
|
fileExists: async () => true
|
|
});
|
|
|
|
assert.equal(result.command, "C:\\Tools\\Git\\cmd\\git.exe");
|
|
assert.equal(result.resolved, false);
|
|
assert.equal(result.source, "explicit-path");
|
|
});
|
|
|
|
test("hwpod-node applies workspace patches through the stable node op", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-patch-"));
|
|
try {
|
|
await writeFile(path.join(root, "main.c"), "int value = 1;\n", "utf8");
|
|
const result = await executeHwpodNodeOpsPlan({
|
|
contractVersion: "hwpod-node-ops-v1",
|
|
planId: "hwpod_plan_patch",
|
|
hwpodId: "hwpod-local",
|
|
nodeId: "pc-host-1",
|
|
ops: [{
|
|
opId: "op_patch",
|
|
op: "workspace.apply-patch",
|
|
args: {
|
|
workspacePath: root,
|
|
patch: [
|
|
"*** Begin Patch",
|
|
"*** Update File: main.c",
|
|
"@@",
|
|
"-int value = 1;",
|
|
"+int value = 2;",
|
|
"*** End Patch",
|
|
""
|
|
].join("\n")
|
|
}
|
|
}]
|
|
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.results[0].ok, true);
|
|
assert.match(await readFile(path.join(root, "main.c"), "utf8"), /value = 2/u);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("hwpod-node preserves CRLF when applying workspace patches", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-patch-crlf-"));
|
|
try {
|
|
await writeFile(path.join(root, "main.c"), "int main(void) {\r\n while (1) {\r\n }\r\n}\r\n", "utf8");
|
|
const result = await executeHwpodNodeOpsPlan({
|
|
contractVersion: "hwpod-node-ops-v1",
|
|
planId: "hwpod_plan_patch_crlf",
|
|
hwpodId: "hwpod-local",
|
|
nodeId: "pc-host-1",
|
|
ops: [{
|
|
opId: "op_patch",
|
|
op: "workspace.apply-patch",
|
|
args: {
|
|
workspacePath: root,
|
|
patch: [
|
|
"*** Begin Patch",
|
|
"*** Update File: main.c",
|
|
"@@",
|
|
" while (1) {",
|
|
"+ /* MARKER_OK */",
|
|
" }",
|
|
"*** End Patch",
|
|
""
|
|
].join("\n")
|
|
}
|
|
}]
|
|
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
|
const content = await readFile(path.join(root, "main.c"), "utf8");
|
|
assert.equal(result.ok, true);
|
|
assert.match(content, /\r\n while \(1\) \{\r\n \/\* MARKER_OK \*\/\r\n \}\r\n/u);
|
|
assert.doesNotMatch(content, /[^\r]\n/u);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("hwpod-node returns apply-patch diagnostics when context is missing", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-patch-diagnostics-"));
|
|
try {
|
|
const original = "int main(void) {\r\n while (1) {\r\n }\r\n}\r\n";
|
|
await writeFile(path.join(root, "main.c"), original, "utf8");
|
|
const result = await executeHwpodNodeOpsPlan({
|
|
contractVersion: "hwpod-node-ops-v1",
|
|
planId: "hwpod_plan_patch_diag",
|
|
hwpodId: "hwpod-local",
|
|
nodeId: "pc-host-1",
|
|
ops: [{
|
|
opId: "op_patch",
|
|
op: "workspace.apply-patch",
|
|
args: {
|
|
workspacePath: root,
|
|
patch: [
|
|
"*** Begin Patch",
|
|
"*** Update File: main.c",
|
|
"@@",
|
|
"- while (2) {",
|
|
"+ while (3) {",
|
|
"*** End Patch",
|
|
""
|
|
].join("\n")
|
|
}
|
|
}]
|
|
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.results[0].blocker.code, "apply_patch_context_not_found");
|
|
assert.equal(result.results[0].blocker.details.fileSha256, sha256(original));
|
|
assert.equal(result.results[0].blocker.details.lineEnding, "\r\n");
|
|
assert.equal(result.results[0].blocker.details.lineEndingCounts.crlf, 4);
|
|
assert.equal(result.results[0].blocker.details.normalized, true);
|
|
assert.match(result.results[0].blocker.details.nodeVersion, /thin-node-ops/u);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("hwpod-node edits CRLF workspaces with insert-after replace and write ops", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-text-edit-"));
|
|
try {
|
|
const filePath = path.join(root, "projects", "01_baseline", "User", "main.c");
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
await writeFile(filePath, "int main(void) {\r\n while (1) {\r\n }\r\n}\r\n", "utf8");
|
|
const before = await readFile(filePath, "utf8");
|
|
const insert = await executeHwpodNodeOpsPlan({
|
|
contractVersion: "hwpod-node-ops-v1",
|
|
planId: "hwpod_plan_insert",
|
|
hwpodId: "hwpod-local",
|
|
nodeId: "pc-host-1",
|
|
ops: [{
|
|
opId: "op_insert",
|
|
op: "workspace.insert-after",
|
|
args: { workspacePath: root, path: "projects/01_baseline/User/main.c", anchor: " while (1) {", line: " /* MARKER_OK */", expectedSha: sha256(before) }
|
|
}]
|
|
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
|
assert.equal(insert.ok, true);
|
|
assert.equal(insert.results[0].output.before.sha256, sha256(before));
|
|
assert.equal(insert.results[0].output.after.lineEnding, "\r\n");
|
|
assert.deepEqual(insert.results[0].output.diff.preview, ["+ /* MARKER_OK */"]);
|
|
const inserted = await readFile(filePath, "utf8");
|
|
assert.match(inserted, /\r\n \/\* MARKER_OK \*\/\r\n/u);
|
|
assert.doesNotMatch(inserted, /[^\r]\n/u);
|
|
|
|
const replace = await executeHwpodNodeOpsPlan({
|
|
contractVersion: "hwpod-node-ops-v1",
|
|
planId: "hwpod_plan_replace",
|
|
hwpodId: "hwpod-local",
|
|
nodeId: "pc-host-1",
|
|
ops: [{
|
|
opId: "op_replace",
|
|
op: "workspace.replace",
|
|
args: { workspacePath: root, path: "projects/01_baseline/User/main.c", find: " /* MARKER_OK */", replace: " /* MARKER_REPLACED */", expectedSha: sha256(inserted) }
|
|
}]
|
|
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
|
assert.equal(replace.ok, true);
|
|
assert.equal(replace.results[0].output.occurrences, 1);
|
|
assert.match(await readFile(filePath, "utf8"), /MARKER_REPLACED/u);
|
|
|
|
const dryRun = await executeHwpodNodeOpsPlan({
|
|
contractVersion: "hwpod-node-ops-v1",
|
|
planId: "hwpod_plan_write_dry",
|
|
hwpodId: "hwpod-local",
|
|
nodeId: "pc-host-1",
|
|
ops: [{
|
|
opId: "op_write",
|
|
op: "workspace.write",
|
|
args: { workspacePath: root, path: "new.txt", content: "alpha\nbeta", lineEnding: "crlf", finalNewline: true, dryRun: true }
|
|
}]
|
|
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
|
assert.equal(dryRun.ok, true);
|
|
assert.equal(dryRun.results[0].output.dryRun, true);
|
|
assert.equal(dryRun.results[0].output.after.lineEnding, "\r\n");
|
|
await assert.rejects(() => readFile(path.join(root, "new.txt"), "utf8"), /ENOENT/u);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("hwpod-node preserves Windows absolute workspace paths for PC-host nodes", async () => {
|
|
const result = await executeHwpodNodeOpsPlan({
|
|
contractVersion: "hwpod-node-ops-v1",
|
|
planId: "hwpod_plan_windows_path",
|
|
hwpodId: "d601-f103-v2",
|
|
nodeId: "node-d601-f103-v2",
|
|
ops: [{ opId: "op_01", op: "workspace.ls", args: { workspacePath: "F:\\Work\\D601-HWLAB", path: "." } }]
|
|
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
|
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.results[0].blocker.code, "ENOENT");
|
|
assert.match(result.results[0].blocker.summary, /scandir 'F:\\Work\\D601-HWLAB'/u);
|
|
assert.doesNotMatch(result.results[0].blocker.summary, /\/root\//u);
|
|
});
|
|
|
|
function sha256(content: string) {
|
|
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
}
|