383 lines
16 KiB
TypeScript
383 lines
16 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { test } from "bun:test";
|
|
|
|
import { runDevicePodCli } from "./src/device-pod-cli-lib.ts";
|
|
|
|
test("device-pod-cli first-admin setup can seed a cloud-api device pod without local profile authority", async () => {
|
|
const seen: any[] = [];
|
|
const profile = { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_admin_seed" } };
|
|
const result = await runDevicePodCli([
|
|
"--api-base-url", "http://cloud.test",
|
|
"--username", "admin",
|
|
"--password", "admin-pass",
|
|
"--pod-id", "device-pod-71-freq",
|
|
"--name", "71-FREQ",
|
|
"--profile-json", JSON.stringify(profile),
|
|
"setup",
|
|
"first-admin"
|
|
], {
|
|
fetchImpl: async (url, init) => {
|
|
seen.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) });
|
|
return new Response(JSON.stringify({ created: true, actor: { id: "usr_admin", role: "admin" }, devicePodBootstrap: { requested: 1, initialized: 1 } }), { status: 201, headers: { "content-type": "application/json", "set-cookie": "hwlab_session=session-a" } });
|
|
},
|
|
now: () => "2026-05-29T00:00:00.000Z"
|
|
});
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.payload.action, "setup.first-admin");
|
|
assert.equal(result.payload.devicePodSeedProvided, true);
|
|
assert.equal(result.payload.localProfileAuthority, false);
|
|
assert.equal(result.payload.setCookie, "hwlab_session=session-a");
|
|
assert.equal(seen[0].url, "http://cloud.test/v1/setup/first-admin");
|
|
assert.equal(seen[0].init.headers["x-hwlab-session-token"], undefined);
|
|
assert.deepEqual(seen[0].body.devicePod, { devicePodId: "device-pod-71-freq", name: "71-FREQ", profile });
|
|
});
|
|
|
|
test("device-pod-cli admin wrappers call cloud-api profile and grant routes", async () => {
|
|
const seen: any[] = [];
|
|
const profile = { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_admin_seed" } };
|
|
const fetchImpl = async (url, init) => {
|
|
seen.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) });
|
|
return jsonResponse(201, { ok: true });
|
|
};
|
|
const upsert = await runDevicePodCli([
|
|
"--api-base-url", "http://cloud.test",
|
|
"--session-token", "session-a",
|
|
"--pod-id", "device-pod-71-freq",
|
|
"--profile-json", JSON.stringify(profile),
|
|
"admin",
|
|
"device-pod",
|
|
"upsert"
|
|
], { fetchImpl, now: () => "2026-05-29T00:00:00.000Z" });
|
|
assert.equal(upsert.exitCode, 0);
|
|
assert.equal(seen[0].url, "http://cloud.test/v1/admin/device-pods");
|
|
assert.equal(seen[0].init.headers["x-hwlab-session-token"], "session-a");
|
|
assert.deepEqual(seen[0].body, { devicePodId: "device-pod-71-freq", profile });
|
|
|
|
const grant = await runDevicePodCli([
|
|
"--api-base-url", "http://cloud.test",
|
|
"--session-token", "session-a",
|
|
"--pod-id", "device-pod-71-freq",
|
|
"--user-id", "usr_alice",
|
|
"admin",
|
|
"grant"
|
|
], { fetchImpl, now: () => "2026-05-29T00:00:00.000Z" });
|
|
assert.equal(grant.exitCode, 0);
|
|
assert.equal(seen[1].url, "http://cloud.test/v1/admin/device-pod-grants");
|
|
assert.deepEqual(seen[1].body, { devicePodId: "device-pod-71-freq", userId: "usr_alice" });
|
|
});
|
|
|
|
test("device-pod-cli lists cloud-api authority and ignores local profile routes", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-device-pod-cli-"));
|
|
const profileDir = path.join(root, ".device-pod");
|
|
await mkdir(profileDir, { recursive: true });
|
|
await writeFile(path.join(profileDir, "device-pod-71-freq.json"), JSON.stringify({ gatewaySessionId: "gws_local_should_not_be_read", hostWorkspaceRoot: "F:\\legacy" }), "utf8");
|
|
const seen: any[] = [];
|
|
const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "profile", "list"], {
|
|
env: { DEVICE_POD_PROFILE_DIR: profileDir },
|
|
fetchImpl: async (url, init) => {
|
|
seen.push({ url: String(url), init });
|
|
return jsonResponse(200, { ok: true, contractVersion: "device-pod-authority-v1", devicePods: [{ devicePodId: "device-pod-71-freq", profileHash: "sha256:abc", profile: { route: { gatewaySessionId: "redacted" } } }] });
|
|
},
|
|
now: () => "2026-05-29T00:00:00.000Z"
|
|
});
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.payload.localProfileAuthority, false);
|
|
assert.equal(result.payload.localProfileRead, false);
|
|
assert.equal(seen[0].url, "http://cloud.test/v1/device-pods");
|
|
assert.equal(seen[0].init.headers["x-hwlab-session-token"], "session-a");
|
|
assert.equal(JSON.stringify(result.payload).includes("gws_local_should_not_be_read"), false);
|
|
});
|
|
|
|
test("device-pod-cli selector creates REST job instead of gateway RPC", async () => {
|
|
const seen: any[] = [];
|
|
const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "device-pod-71-freq:workspace:/src", "ls"], {
|
|
fetchImpl: async (url, init) => {
|
|
seen.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) });
|
|
return jsonResponse(202, { ok: true, status: "running", job: { id: "job_1", status: "running" }, profileHash: "sha256:abc" });
|
|
},
|
|
now: () => "2026-05-29T00:00:00.000Z"
|
|
});
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(seen[0].url, "http://cloud.test/v1/device-pods/device-pod-71-freq/jobs");
|
|
assert.deepEqual(seen[0].body, { intent: "workspace.ls", args: { path: "src" } });
|
|
assert.equal(JSON.stringify(seen[0].body).includes("gatewaySessionId"), false);
|
|
});
|
|
|
|
test("device-pod-cli bootsharp creates a formal workspace bootsharp REST job", async () => {
|
|
const seen: any[] = [];
|
|
const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "bootsharp", "--pod-id", "D601-F103-V2", "--depth", "2"], {
|
|
fetchImpl: async (url, init) => {
|
|
seen.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) });
|
|
return jsonResponse(202, { ok: true, status: "running", job: { id: "job_bootsharp", status: "running" }, profileHash: "sha256:abc" });
|
|
},
|
|
now: () => "2026-05-31T00:00:00.000Z"
|
|
});
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(seen[0].url, "http://cloud.test/v1/device-pods/D601-F103-V2/jobs");
|
|
assert.deepEqual(seen[0].body, { intent: "workspace.bootsharp", args: { path: ".", depth: 2 } });
|
|
assert.equal(result.payload.localProfileAuthority, false);
|
|
});
|
|
|
|
test("device-pod-cli job output is compact by default and full with --full", async () => {
|
|
const payload = {
|
|
serviceId: "hwlab-cloud-api",
|
|
contractVersion: "device-pod-job-v1",
|
|
status: "completed",
|
|
devicePodId: "D601-F103-V2",
|
|
traceId: "trc_job",
|
|
operationId: "op_job",
|
|
job: { id: "job_1", status: "completed" },
|
|
output: {
|
|
text: "x".repeat(5000),
|
|
dispatch: { command: "verbose command that should stay out of compact output" },
|
|
bytes: 5000,
|
|
summary: "gateway/device-host-cli dispatch completed"
|
|
}
|
|
};
|
|
const fetchImpl = async () => jsonResponse(200, payload);
|
|
const compact = await runDevicePodCli([
|
|
"--api-base-url", "http://cloud.test",
|
|
"--session-token", "session-a",
|
|
"job", "output",
|
|
"--pod-id", "D601-F103-V2",
|
|
"job_1"
|
|
], { fetchImpl, now: () => "2026-05-31T00:00:00.000Z" });
|
|
assert.equal(compact.exitCode, 0);
|
|
assert.equal(compact.payload.body.compacted, true);
|
|
assert.equal(compact.payload.body.text.length, 4000);
|
|
assert.equal(JSON.stringify(compact.payload).includes("verbose command"), false);
|
|
|
|
const full = await runDevicePodCli([
|
|
"--api-base-url", "http://cloud.test",
|
|
"--session-token", "session-a",
|
|
"--full",
|
|
"job", "output",
|
|
"--pod-id", "D601-F103-V2",
|
|
"job_1"
|
|
], { fetchImpl, now: () => "2026-05-31T00:00:00.000Z" });
|
|
assert.equal(full.exitCode, 0);
|
|
assert.equal(full.payload.full, true);
|
|
assert.equal(full.payload.body.output.dispatch.command, "verbose command that should stay out of compact output");
|
|
});
|
|
|
|
test("device-pod-cli sends reason and lease token for mutating jobs", async () => {
|
|
const seen: any[] = [];
|
|
const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "--lease-token", "lease-a", "--reason", "reset smoke", "device-pod-71-freq:debug-probe", "reset"], {
|
|
fetchImpl: async (url, init) => {
|
|
seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) });
|
|
return jsonResponse(202, { ok: true, status: "running", job: { id: "job_reset", status: "running" } });
|
|
},
|
|
now: () => "2026-05-29T00:00:00.000Z"
|
|
});
|
|
assert.equal(result.exitCode, 0);
|
|
assert.deepEqual(seen[0].body, { intent: "debug.reset", args: {}, reason: "reset smoke", leaseToken: "lease-a" });
|
|
});
|
|
|
|
test("device-pod-cli preserves common v0.1 hardware options in REST job args", async () => {
|
|
const seen: any[] = [];
|
|
const result = await runDevicePodCli([
|
|
"--api-base-url", "http://cloud.test",
|
|
"--session-token", "session-a",
|
|
"--reason", "boot log",
|
|
"--lease-token", "lease-a",
|
|
"--capture-uart", "uart/1",
|
|
"--capture-duration-ms", "8000",
|
|
"--port", "COM4",
|
|
"--baud-rate", "921600",
|
|
"device-pod-71-freq:debug-probe",
|
|
"download",
|
|
"start"
|
|
], {
|
|
fetchImpl: async (url, init) => {
|
|
seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) });
|
|
return jsonResponse(202, { ok: true, status: "running", job: { id: "job_download", status: "running" } });
|
|
},
|
|
now: () => "2026-05-29T00:00:00.000Z"
|
|
});
|
|
assert.equal(result.exitCode, 0);
|
|
assert.deepEqual(seen[0].body, {
|
|
intent: "debug.download",
|
|
args: { action: "start", captureUart: "uart/1", captureDurationMs: 8000, port: "COM4", baudRate: 921600 },
|
|
reason: "boot log",
|
|
leaseToken: "lease-a"
|
|
});
|
|
});
|
|
|
|
test("device-pod-cli exposes workspace writes, keil source edits, and UART JSON-RPC as REST jobs", async () => {
|
|
const seen: any[] = [];
|
|
const fetchImpl = async (url, init) => {
|
|
seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) });
|
|
return jsonResponse(202, { ok: true, status: "running", job: { id: `job_${seen.length}`, status: "running" } });
|
|
};
|
|
|
|
const put = await runDevicePodCli([
|
|
"--api-base-url", "http://cloud.test",
|
|
"--session-token", "session-a",
|
|
"--reason", "write file",
|
|
"--lease-token", "lease-a",
|
|
"device-pod-71-freq:workspace:/projects/app",
|
|
"put",
|
|
"User/new.c",
|
|
"--create-dirs"
|
|
], { fetchImpl, stdinText: "int main(void) { return 0; }\n", now: () => "2026-05-29T00:00:00.000Z" });
|
|
assert.equal(put.exitCode, 0);
|
|
assert.equal(seen[0].body.intent, "workspace.put");
|
|
assert.equal(seen[0].body.args.path, "projects/app/User/new.c");
|
|
assert.equal(seen[0].body.args.createDirs, true);
|
|
assert.equal(Buffer.from(seen[0].body.args.contentB64, "base64").toString("utf8"), "int main(void) { return 0; }\n");
|
|
|
|
const keil = await runDevicePodCli([
|
|
"--api-base-url", "http://cloud.test",
|
|
"--session-token", "session-a",
|
|
"--reason", "add source",
|
|
"--lease-token", "lease-a",
|
|
"device-pod-71-freq:workspace:/projects/app",
|
|
"keil",
|
|
"add-source",
|
|
"User/new.c",
|
|
"--group", "User"
|
|
], { fetchImpl, now: () => "2026-05-29T00:00:00.000Z" });
|
|
assert.equal(keil.exitCode, 0);
|
|
assert.deepEqual(seen[1].body.args, { action: "add-source", base: "projects/app", path: "User/new.c", group: "User" });
|
|
|
|
const jsonrpc = await runDevicePodCli([
|
|
"--api-base-url", "http://cloud.test",
|
|
"--session-token", "session-a",
|
|
"--reason", "gpio read",
|
|
"--lease-token", "lease-a",
|
|
"device-pod-71-freq:io-probe:/uart/1",
|
|
"jsonrpc",
|
|
"gpio.read",
|
|
"--params-json", "{\"pin\":\"PB5\"}",
|
|
"--require-jsonrpc-result",
|
|
"--expect-result-field", "pin",
|
|
"--expect-result-field", "value"
|
|
], { fetchImpl, now: () => "2026-05-29T00:00:00.000Z" });
|
|
assert.equal(jsonrpc.exitCode, 0);
|
|
assert.equal(seen[2].body.intent, "io.uart.jsonrpc");
|
|
assert.deepEqual(seen[2].body.args.expectResultField, ["pin", "value"]);
|
|
assert.equal(seen[2].body.args.params, "{\"pin\":\"PB5\"}");
|
|
});
|
|
|
|
test("device-pod-cli rejects legacy local profile create in formal mode", async () => {
|
|
const result = await runDevicePodCli(["profile", "create", "--pod-id", "device-pod-71-freq"], { now: () => "2026-05-29T00:00:00.000Z" });
|
|
assert.equal(result.exitCode, 1);
|
|
assert.equal(result.payload.error.code, "legacy_profile_create_removed");
|
|
});
|
|
|
|
test("device-pod-cli keeps debug-probe status as a probe operation, not job status", async () => {
|
|
const seen: any[] = [];
|
|
const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "D601-F103-V2:debug-probe", "status"], {
|
|
fetchImpl: async (url, init) => {
|
|
seen.push({ url: String(url), init });
|
|
return jsonResponse(200, { ok: true, devicePodId: "D601-F103-V2", status: "ok" });
|
|
},
|
|
now: () => "2026-05-31T00:00:00.000Z"
|
|
});
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(seen[0].url, "http://cloud.test/v1/device-pods/D601-F103-V2/status");
|
|
assert.equal(result.payload.action, "debug.status");
|
|
});
|
|
|
|
test("device-pod-cli forwards host build and download job ids as named device-pod jobs", async () => {
|
|
const seen: any[] = [];
|
|
const fetchImpl = async (url, init) => {
|
|
seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) });
|
|
return jsonResponse(202, { ok: true, status: "running", job: { id: `job_${seen.length}`, status: "running" } });
|
|
};
|
|
const build = await runDevicePodCli([
|
|
"--api-base-url", "http://cloud.test",
|
|
"--session-token", "session-a",
|
|
"D601-F103-V2:workspace:/",
|
|
"build",
|
|
"status",
|
|
"20260531101208650-keil-build-030aab"
|
|
], { fetchImpl, now: () => "2026-05-31T00:00:00.000Z" });
|
|
assert.equal(build.exitCode, 0);
|
|
assert.deepEqual(seen[0].body, {
|
|
intent: "workspace.build",
|
|
args: { action: "status", jobId: "20260531101208650-keil-build-030aab" }
|
|
});
|
|
|
|
const download = await runDevicePodCli([
|
|
"--api-base-url", "http://cloud.test",
|
|
"--session-token", "session-a",
|
|
"D601-F103-V2:debug-probe",
|
|
"download",
|
|
"status",
|
|
"20260531103014862-keil-download-177dd9"
|
|
], { fetchImpl, now: () => "2026-05-31T00:00:00.000Z" });
|
|
assert.equal(download.exitCode, 0);
|
|
assert.deepEqual(seen[1].body, {
|
|
intent: "debug.download",
|
|
args: { action: "status", jobId: "20260531103014862-keil-download-177dd9" }
|
|
});
|
|
});
|
|
|
|
test("device-pod-cli maps selector build/download cloud job aliases to job routes", async () => {
|
|
const seen: any[] = [];
|
|
const fetchImpl = async (url, init) => {
|
|
seen.push({ url: String(url), method: init?.method });
|
|
return jsonResponse(200, { ok: true, status: "completed", output: { text: "done" } });
|
|
};
|
|
|
|
const build = await runDevicePodCli([
|
|
"--api-base-url", "http://cloud.test",
|
|
"--session-token", "session-a",
|
|
"D601-F103-V2:workspace:/",
|
|
"build",
|
|
"status",
|
|
"job_devicepod_build"
|
|
], { fetchImpl, now: () => "2026-05-31T00:00:00.000Z" });
|
|
assert.equal(build.exitCode, 0);
|
|
assert.equal(build.payload.action, "job.status");
|
|
assert.equal(seen[0].url, "http://cloud.test/v1/device-pods/D601-F103-V2/jobs/job_devicepod_build");
|
|
|
|
const download = await runDevicePodCli([
|
|
"--api-base-url", "http://cloud.test",
|
|
"--session-token", "session-a",
|
|
"D601-F103-V2:debug-probe",
|
|
"download",
|
|
"output",
|
|
"job_devicepod_download"
|
|
], { fetchImpl, now: () => "2026-05-31T00:00:00.000Z" });
|
|
assert.equal(download.exitCode, 0);
|
|
assert.equal(download.payload.action, "job.output");
|
|
assert.equal(seen[1].url, "http://cloud.test/v1/device-pods/D601-F103-V2/jobs/job_devicepod_download/output");
|
|
});
|
|
|
|
test("device-pod-cli accepts explicit workspace rg pattern and path options", async () => {
|
|
const seen: any[] = [];
|
|
const result = await runDevicePodCli([
|
|
"--api-base-url", "http://cloud.test",
|
|
"--session-token", "session-a",
|
|
"D601-F103-V2:workspace:/",
|
|
"rg",
|
|
"--pattern",
|
|
"Programming Done|Verify OK|Application running|Error",
|
|
"--path",
|
|
"projects/01_baseline/captures"
|
|
], {
|
|
fetchImpl: async (url, init) => {
|
|
seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) });
|
|
return jsonResponse(202, { ok: true, status: "running", job: { id: "job_rg", status: "running" } });
|
|
},
|
|
now: () => "2026-05-31T00:00:00.000Z"
|
|
});
|
|
assert.equal(result.exitCode, 0);
|
|
assert.deepEqual(seen[0].body, {
|
|
intent: "workspace.rg",
|
|
args: {
|
|
pattern: "Programming Done|Verify OK|Application running|Error",
|
|
path: "projects/01_baseline/captures"
|
|
}
|
|
});
|
|
});
|
|
|
|
function jsonResponse(status: number, body: unknown) {
|
|
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
}
|