Files
pikasTech-HWLAB/internal/cloud/hwpod-node-ops.test.ts
T

405 lines
15 KiB
TypeScript

import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { test } from "bun:test";
import { createCloudApiBunServer } from "./bun-server.ts";
import { createCloudApiServer } from "./server.ts";
import { connectHwpodNodeWs, createHwpodNodeServer } from "../../tools/src/hwpod-node-lib.ts";
test("cloud-api exposes hwpod-node-ops contract on /v1", async () => {
const server = createCloudApiServer();
await listen(server);
try {
const response = await fetch(`${serverUrl(server)}/v1`);
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.hwpod.contractVersion, "hwpod-node-ops-v1");
assert.equal(payload.hwpod.apiRole, "node-ops-forwarder");
assert.equal(payload.hwpod.specDiscoveryRoute, "/v1/hwpod/specs");
assert.ok(payload.hwpod.supportedOps.includes("workspace.ls"));
assert.ok(payload.hwpod.supportedOps.includes("workspace.insert-after"));
assert.equal(payload.hwlabNode.updateRoute, "/v1/hwlab-node/update");
assert.equal(payload.hwlabNode.downloadRoute, "/v1/hwlab-node/download/hwlab-node.py");
assert.equal(payload.hwlabNode.updateContractVersion, "hwlab-node-update-v1");
} finally {
await close(server);
}
});
test("cloud-api exposes hwlab-node Python update metadata", async () => {
const server = createCloudApiServer({
env: {
PATH: process.env.PATH,
HWLAB_PUBLIC_ENDPOINT: "https://hwlab.pikapython.com",
HWLAB_NODE_PY_LATEST_VERSION: "0.1.1",
HWLAB_NODE_PY_DOWNLOAD_PATH: "/downloads/hwlab-node.py",
HWLAB_NODE_PY_SHA256: "abc123"
}
});
await listen(server);
try {
const response = await fetch(`${serverUrl(server)}/v1/hwlab-node/update?current=0.1.0&channel=stable&platform=windows`);
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.ok, true);
assert.equal(payload.contractVersion, "hwlab-node-update-v1");
assert.equal(payload.updateAvailable, true);
assert.equal(payload.latestVersion, "0.1.1");
assert.equal(payload.downloadUrl, "https://hwlab.pikapython.com/downloads/hwlab-node.py");
assert.equal(payload.sha256, "abc123");
const currentResponse = await fetch(`${serverUrl(server)}/v1/hwlab-node/update?current=0.1.1&channel=stable&platform=windows`);
const currentPayload = await currentResponse.json();
assert.equal(currentResponse.status, 200);
assert.equal(currentPayload.updateAvailable, false);
assert.equal(currentPayload.downloadUrl, null);
} finally {
await close(server);
}
});
test("cloud-api serves bundled hwlab-node Python updater artifact by default", async () => {
const server = createCloudApiServer({ env: { PATH: process.env.PATH, HWLAB_PUBLIC_ENDPOINT: "https://hwlab.pikapython.com" } });
await listen(server);
try {
const response = await fetch(`${serverUrl(server)}/v1/hwlab-node/update?current=0.1.0&channel=stable&platform=windows`);
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.updateAvailable, true);
assert.equal(payload.latestVersion, "0.1.1");
assert.equal(payload.downloadUrl, "https://hwlab.pikapython.com/v1/hwlab-node/download/hwlab-node.py");
assert.match(payload.sha256, /^[a-f0-9]{64}$/u);
const download = await fetch(`${serverUrl(server)}/v1/hwlab-node/download/hwlab-node.py`);
const text = await download.text();
assert.equal(download.status, 200);
assert.equal(download.headers.get("x-hwlab-node-version"), "0.1.1");
assert.ok(text.includes('APP_VERSION = "0.1.1"'));
} finally {
await close(server);
}
});
test("cloud-api discovers preinstalled workspace hwpod-specs without hardcoded device constants", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-spec-discovery-"));
const specDir = path.join(root, ".hwlab");
await mkdir(specDir, { recursive: true });
await writeFile(path.join(specDir, "hwpod-spec.yaml"), sampleSpecYaml(), "utf8");
await writeFile(path.join(specDir, "hwpod-spec.meta.json"), JSON.stringify({ source: { kind: "preinstalled-verified-spec", caseRepo: "pikasTech/hwlab-case-registry", caseId: "d601-f103-v2-compile" } }), "utf8");
const server = createCloudApiServer({ env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: root } });
await listen(server);
try {
const response = await fetch(`${serverUrl(server)}/v1/hwpod/specs`);
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.contractVersion, "hwpod-spec-discovery-v1");
assert.equal(payload.count, 1);
assert.equal(payload.specs[0].name, "d601-f103-v2");
assert.equal(payload.specs[0].uid, "D601-F103-V2");
assert.equal(payload.specs[0].nodeId, "node-d601-f103-v2");
assert.equal(payload.specs[0].source.caseRepo, "pikasTech/hwlab-case-registry");
assert.equal(payload.specs[0].source.caseId, "d601-f103-v2-compile");
} finally {
await close(server);
await rm(root, { recursive: true, force: true });
}
});
test("cloud-api probes discovered hwpod-spec availability through node-ops", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-spec-probe-"));
const specDir = path.join(root, ".hwlab");
await mkdir(specDir, { recursive: true });
await writeFile(path.join(specDir, "hwpod-spec.yaml"), sampleSpecYaml(), "utf8");
const seen: any[] = [];
const server = createCloudApiServer({
env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: root },
hwpodNodeOpsHandler: async (plan: any) => {
seen.push(plan);
return { ok: true, status: "completed", results: plan.ops.map((op: any) => ({ opId: op.opId, op: op.op, ok: true, status: "completed", output: { path: op.args.workspacePath } })) };
}
});
await listen(server);
try {
const response = await fetch(`${serverUrl(server)}/v1/hwpod/specs?probe=1`);
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.count, 1);
assert.equal(payload.availableCount, 1);
assert.equal(payload.specs[0].availability.status, "available");
assert.equal(seen[0].hwpodId, "d601-f103-v2");
assert.equal(seen[0].nodeId, "node-d601-f103-v2");
assert.equal(seen[0].ops[0].op, "workspace.ls");
} finally {
await close(server);
await rm(root, { recursive: true, force: true });
}
});
test("cloud-api forwards valid hwpod-node-ops plans to injected node handler", async () => {
const seen: any[] = [];
const server = createCloudApiServer({
hwpodNodeOpsHandler: async (plan: any, context: any) => {
seen.push({ plan, context });
return {
ok: true,
status: "completed",
results: plan.ops.map((op: any) => ({ opId: op.opId, op: op.op, ok: true, status: "completed", output: { sample: true } }))
};
}
});
await listen(server);
try {
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
method: "POST",
headers: { "content-type": "application/json", "x-trace-id": "trc_hwpod_test" },
body: JSON.stringify(samplePlan())
});
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.ok, true);
assert.equal(payload.status, "completed");
assert.equal(payload.contractVersion, "hwpod-node-ops-v1");
assert.equal(payload.results[0].op, "workspace.ls");
assert.equal(seen.length, 1);
assert.equal(seen[0].plan.nodeId, "pc-host-1");
assert.equal(seen[0].context.requestMeta.traceId, "trc_hwpod_test");
} finally {
await close(server);
}
});
test("cloud-api returns blocked payload when hwpod-node is not wired yet", async () => {
const server = createCloudApiServer();
await listen(server);
try {
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(samplePlan())
});
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.ok, false);
assert.equal(payload.status, "blocked");
assert.equal(payload.blocker.code, "hwpod_node_unavailable");
assert.equal(payload.results[0].blocker.layer, "hwpod-node");
} finally {
await close(server);
}
});
test("cloud-api forwards hwpod-node-ops to configured thin hwpod-node URL", async () => {
const node = createHwpodNodeServer({ nodeId: "pc-host-1" });
await listen(node);
const server = createCloudApiServer({
env: {
PATH: process.env.PATH,
HWLAB_HWPOD_NODE_OPS_URL: `${serverUrl(node)}/v1/hwpod-node-ops`
}
});
await listen(server);
try {
const plan = samplePlan();
plan.ops[0].args.workspacePath = process.cwd();
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(plan)
});
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.ok, true);
assert.equal(payload.status, "completed");
assert.equal(payload.results[0].op, "workspace.ls");
assert.equal(payload.results[0].output.entries.some((entry: any) => entry.name === "package.json"), true);
} finally {
await close(server);
await close(node);
}
});
test("cloud-api blocks direct hwpod-node URL when target node id does not match", async () => {
const node = createHwpodNodeServer({ nodeId: "g14-host-hwpod-node" });
await listen(node);
const server = createCloudApiServer({
env: {
PATH: process.env.PATH,
HWPOD_NODE_ID: "g14-host-hwpod-node",
HWLAB_HWPOD_NODE_OPS_URL: `${serverUrl(node)}/v1/hwpod-node-ops`
}
});
await listen(server);
try {
const plan = samplePlan();
plan.nodeId = "node-d601-f103-v2";
plan.ops[0].args.workspacePath = "F:\\Work\\HWLAB-CASE-F103";
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(plan)
});
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.ok, false);
assert.equal(payload.status, "blocked");
assert.equal(payload.blocker.code, "hwpod_node_id_mismatch");
assert.equal(payload.blocker.details.requestedNodeId, "node-d601-f103-v2");
assert.equal(payload.blocker.details.targetNodeId, "g14-host-hwpod-node");
assert.equal(payload.results[0].blocker.code, "hwpod_node_id_mismatch");
} finally {
await close(server);
await close(node);
}
});
test("cloud-api blocks direct hwpod-node URL when node identity is not visible", async () => {
const target = createCloudApiServer({
hwpodNodeOpsHandler: async (plan: any) => ({
ok: true,
status: "completed",
results: plan.ops.map((op: any) => ({ opId: op.opId, op: op.op, ok: true, status: "completed", output: { shouldNotRun: true } }))
})
});
await listen(target);
const server = createCloudApiServer({
env: {
PATH: process.env.PATH,
HWLAB_HWPOD_NODE_OPS_URL: `${serverUrl(target)}/v1/hwpod-node-ops`
}
});
await listen(server);
try {
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(samplePlan())
});
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.ok, false);
assert.equal(payload.status, "blocked");
assert.equal(payload.blocker.code, "hwpod_node_identity_unverified");
assert.equal(payload.results[0].output, undefined);
} finally {
await close(server);
await close(target);
}
});
test("cloud-api dispatches hwpod-node-ops to outbound WebSocket hwpod-node by nodeId", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-ws-"));
const runtime = await createCloudApiBunServer({ host: "127.0.0.1", port: 0, env: { PATH: process.env.PATH } });
const connector = connectHwpodNodeWs({ cloudUrl: runtime.url, nodeId: "pc-host-1", reconnect: false, heartbeatIntervalMs: 1000 });
try {
await writeFile(path.join(root, "main.c"), "int main(void) { return 0; }\n", "utf8");
await withTimeout(connector.ready, 3000, "hwpod-node WebSocket registration timed out");
const plan = samplePlan();
plan.ops[0].args.workspacePath = root;
const response = await fetch(`${runtime.url}/v1/hwpod-node-ops`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(plan)
});
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.ok, true);
assert.equal(payload.status, "completed");
assert.equal(payload.nodeId, "pc-host-1");
assert.equal(payload.results[0].op, "workspace.ls");
assert.equal(payload.results[0].output.entries.some((entry: any) => entry.name === "main.c"), true);
} finally {
connector.close();
runtime.stop();
await rm(root, { recursive: true, force: true });
}
});
test("cloud-api rejects unsupported hwpod-node ops before forwarding", async () => {
const server = createCloudApiServer({
hwpodNodeOpsHandler: async () => {
throw new Error("must not be called");
}
});
await listen(server);
try {
const plan = samplePlan();
plan.ops[0].op = "debug.flash";
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(plan)
});
const payload = await response.json();
assert.equal(response.status, 400);
assert.equal(payload.ok, false);
assert.equal(payload.error.code, "unsupported_hwpod_node_op");
} finally {
await close(server);
}
});
function samplePlan() {
return {
contractVersion: "hwpod-node-ops-v1",
planId: "hwpod_plan_test",
hwpodId: "hwpod-local",
nodeId: "pc-host-1",
intent: "workspace.ls",
ops: [
{ opId: "op_01_workspace_ls", op: "workspace.ls", args: { workspacePath: "/workspace/fw", path: "." } }
]
};
}
function sampleSpecYaml() {
return `apiVersion: hwlab.dev/v0alpha1
kind: Hwpod
metadata:
uid: D601-F103-V2
name: d601-f103-v2
spec:
targetDevice:
board: D601-F103-V2
mcu: STM32F103
workspace:
path: "F:\\\\Work\\\\D601-HWLAB"
toolchain: keil-mdk
debugProbe:
type: daplink
ioProbe:
uart:
port: COM9
nodeBinding:
nodeId: node-d601-f103-v2
nodeType: pc-host
`;
}
async function listen(server: any) {
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
}
async function close(server: any) {
await new Promise((resolve, reject) => server.close((error: Error | undefined) => (error ? reject(error) : resolve(undefined))));
}
function serverUrl(server: any) {
return `http://127.0.0.1:${server.address().port}`;
}
async function withTimeout(promise: Promise<unknown>, timeoutMs: number, message: string) {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
promise,
new Promise((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
})
]);
} finally {
if (timer !== null) clearTimeout(timer);
}
}