Files
api/services/edge-agent/test/agent.test.mjs
T
Jeppe Bundgaard 17d855d04f Refactor edge agent test with waitFor utility and update GitHub Actions workflow
- Introduced `waitFor` utility in edge agent tests for more reliable condition polling.
- Adjusted test assertions to use `waitFor` for verifying agent polling activity.
- Added separate GitHub Actions jobs for `edge-agent` and `edge-broker` to improve test isolation.
2026-04-14 16:31:47 +02:00

562 lines
16 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { execFile as execFileCallback } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import {
buildStatusReport,
claimIfNeeded,
createShellBridge,
getAgentStatus,
getRelayStatus,
parseCliArgs,
runCli,
setRelayState,
startAgent,
} from "../dist/agent.mjs";
const execFile = promisify(execFileCallback);
const agentEntryPath = fileURLToPath(new URL("../dist/agent.mjs", import.meta.url));
async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, description = "condition" } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`Timed out waiting for ${description}`);
}
test("claimIfNeeded persists claimed gateway credentials", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-"));
const configPath = path.join(tempDir, "config.json");
await writeFile(configPath, JSON.stringify({
apiUrl: "https://api.example.test",
installToken: "claim-token",
}));
const requests = [];
const fakeFetch = async (url, options) => {
requests.push({ url, options });
return {
ok: true,
async json() {
return {
data: {
gateway: { id: 9001 },
agent_token: "agent-token",
release_channel: "stable",
},
};
},
};
};
const config = await claimIfNeeded({
apiUrl: "https://api.example.test",
installToken: "claim-token",
}, configPath, fakeFetch);
assert.equal(requests.length, 1);
assert.equal(config.gatewayId, 9001);
assert.equal(config.agentToken, "agent-token");
assert.equal("brokerUrl" in config, false);
await rm(tempDir, { recursive: true, force: true });
});
test("relay status and switch commands support both Shelly RPC and legacy endpoints", async () => {
const fakeFetch = async (url) => {
if (String(url).includes("Switch.GetStatus")) {
return {
ok: true,
async json() {
return { output: true };
},
};
}
if (String(url).includes("Switch.Set")) {
return {
ok: true,
async json() {
return { output: false };
},
};
}
throw new Error(`Unexpected URL: ${url}`);
};
const status = await getRelayStatus({ localIp: "10.1.0.31", channel: 0 }, fakeFetch);
const switched = await setRelayState({ localIp: "10.1.0.31", channel: 0, on: false }, fakeFetch);
assert.equal(status.online, true);
assert.equal(status.on, true);
assert.equal(switched.on, false);
});
test("shell bridge proxies PTY output, input, resize, close, and dispose events", async () => {
const messages = [];
const createdPtys = [];
const shell = createShellBridge(
(message) => messages.push(message),
{
createPtyProcess: async (options) => {
const listeners = {
data: null,
exit: null,
};
const pty = {
options,
writes: [],
resizes: [],
killed: false,
onData(callback) {
listeners.data = callback;
return {
dispose() {
listeners.data = null;
},
};
},
onExit(callback) {
listeners.exit = callback;
return {
dispose() {
listeners.exit = null;
},
};
},
write(data) {
this.writes.push(data);
},
resize(cols, rows) {
this.resizes.push({ cols, rows });
},
kill() {
this.killed = true;
listeners.exit?.({ exitCode: 0 });
},
emitData(data) {
listeners.data?.(data);
},
};
createdPtys.push(pty);
return pty;
},
}
);
await shell.open({
sessionId: "test-shell",
shellCommand: "/bin/bash",
shellArgs: ["-l"],
cols: 90,
rows: 24,
cwd: "/tmp",
});
createdPtys[0].emitData("root@pi:~# ");
shell.input({ sessionId: "test-shell", data: "ls\r" });
shell.resize({ sessionId: "test-shell", cols: 120, rows: 40 });
shell.dispose();
assert.equal(createdPtys.length, 1);
assert.equal(createdPtys[0].options.command, "/bin/bash");
assert.deepEqual(createdPtys[0].options.args, ["-l"]);
assert.equal(createdPtys[0].options.cols, 90);
assert.equal(createdPtys[0].options.rows, 24);
assert.equal(createdPtys[0].options.cwd, "/tmp");
assert.deepEqual(createdPtys[0].writes, ["ls\r"]);
assert.deepEqual(createdPtys[0].resizes, [{ cols: 120, rows: 40 }]);
assert.equal(createdPtys[0].killed, true);
assert.ok(messages.some((message) => message.type === "SHELL_OPENED" && message.sessionId === "test-shell"));
assert.ok(messages.some((message) => message.type === "SHELL_OUTPUT" && message.data.includes("root@pi")));
assert.ok(messages.some((message) => message.type === "SHELL_EXIT" && message.code === 0));
});
test("startAgent reports API polling metadata, executes polled commands, and uploads shell events", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-"));
const configPath = path.join(tempDir, "config.json");
await writeFile(configPath, JSON.stringify({
apiUrl: "https://api.example.test",
gatewayId: 42,
agentToken: "agent-token",
heartbeatIntervalSeconds: 0.05,
commandPollTimeoutSeconds: 0,
commandPollRetryDelayMs: 5,
shellActionPollTimeoutSeconds: 0,
shellActionPollRetryDelayMs: 5,
}));
const heartbeats = [];
const commandResultPosts = [];
const shellActionResults = [];
const shellEventPosts = [];
let polledCommandDelivered = false;
let openActionDelivered = false;
let closeActionDelivered = false;
let metricSample = 0;
const fakeFetch = async (url, options = {}) => {
const body = options.body ? JSON.parse(options.body) : {};
if (String(url).endsWith("/heartbeat")) {
heartbeats.push({ url, body });
return {
ok: true,
async json() {
return { data: { ok: true } };
},
};
}
if (String(url).endsWith("/commands/poll")) {
if (!polledCommandDelivered) {
polledCommandDelivered = true;
return {
ok: true,
async json() {
return {
data: {
id: 99,
commandType: "DISCOVER_SHELLY",
payload: {
candidateIps: ["10.1.0.31"],
},
},
};
},
};
}
await new Promise((resolve) => setTimeout(resolve, 5));
return {
ok: true,
async json() {
return { data: null };
},
};
}
if (String(url).endsWith("/commands/99/result")) {
commandResultPosts.push({ url, body });
return {
ok: true,
async json() {
return { data: { acknowledged: true } };
},
};
}
if (String(url).endsWith("/shell-actions/poll")) {
if (!openActionDelivered) {
openActionDelivered = true;
return {
ok: true,
async json() {
return {
data: {
id: 501,
actionType: "OPEN",
payload: {
sessionId: 44,
cols: 100,
rows: 30,
reason: "Investigate relay drift",
},
},
};
},
};
}
if (!closeActionDelivered) {
closeActionDelivered = true;
return {
ok: true,
async json() {
return {
data: {
id: 502,
actionType: "CLOSE",
payload: {
sessionId: 44,
},
},
};
},
};
}
await new Promise((resolve) => setTimeout(resolve, 5));
return {
ok: true,
async json() {
return { data: null };
},
};
}
if (/\/shell-actions\/\d+\/result$/.test(String(url))) {
shellActionResults.push({ url, body });
return {
ok: true,
async json() {
return { data: { acknowledged: true } };
},
};
}
if (String(url).includes("/shell-sessions/44/events")) {
shellEventPosts.push({ url, body });
return {
ok: true,
async json() {
return { data: { acknowledged: true } };
},
};
}
if (String(url) === "http://10.1.0.31/shelly") {
return {
ok: true,
async json() {
return {
mac: "AA:BB:CC:DD:EE:FF",
model: "Shelly Plus 1PM",
num_switches: 1,
};
},
};
}
throw new Error(`Unexpected URL: ${url}`);
};
const collectMetricsImpl = async ({ latencyMs }) => {
metricSample += 1;
return {
cpuSnapshot: { idle: metricSample, total: metricSample + 10 },
metrics: {
latency_ms: latencyMs,
cpu_usage_pct: 27.5,
memory_usage_pct: 48.2,
memory_used_bytes: 2048,
memory_total_bytes: 4096,
disk_usage_pct: 61.4,
disk_used_bytes: 8192,
disk_total_bytes: 16384,
disk_mount: "/",
},
};
};
const shellCalls = [];
const createShellBridgeImpl = (sendMessage) => ({
async open(payload) {
shellCalls.push({ type: "open", payload });
sendMessage({ type: "SHELL_OPENED", sessionId: payload.sessionId });
sendMessage({ type: "SHELL_OUTPUT", sessionId: payload.sessionId, data: "root@pi:~# " });
},
input(payload) {
shellCalls.push({ type: "input", payload });
},
resize(payload) {
shellCalls.push({ type: "resize", payload });
},
close(payload) {
shellCalls.push({ type: "close", payload });
sendMessage({ type: "SHELL_EXIT", sessionId: payload.sessionId, code: 0 });
},
dispose() {
shellCalls.push({ type: "dispose" });
},
});
const agent = await startAgent({
configPath,
fetchImpl: fakeFetch,
collectMetricsImpl,
createShellBridgeImpl,
});
assert.equal(heartbeats[0].body.status, "ONLINE");
assert.equal(heartbeats[0].body.metadata.command_transport, "API_POLLING");
assert.equal(heartbeats[0].body.metadata.shell_transport, "API_POLLING");
assert.equal(heartbeats[0].body.metadata.system_metrics.cpu_usage_pct, 27.5);
assert.equal(heartbeats[0].body.metadata.system_metrics.memory_usage_pct, 48.2);
assert.equal(heartbeats[0].body.metadata.system_metrics.disk_usage_pct, 61.4);
assert.equal(heartbeats[0].body.metadata.system_metrics.latency_ms, null);
assert.equal("broker_connected" in heartbeats[0].body.metadata, false);
assert.equal("discovery_status" in heartbeats[0].body, false);
await waitFor(
() =>
commandResultPosts.length === 1 &&
shellActionResults.length === 2 &&
shellEventPosts.length > 0 &&
heartbeats.some(
(heartbeat) =>
heartbeat.body.metadata.system_metrics &&
typeof heartbeat.body.metadata.system_metrics.latency_ms === "number"
),
{ timeoutMs: 1000, description: "agent polling activity and follow-up heartbeat" }
);
assert.equal(commandResultPosts.length, 1);
assert.equal(commandResultPosts[0].body.ok, true);
assert.equal(Array.isArray(commandResultPosts[0].body.payload.inventory), true);
assert.equal(commandResultPosts[0].body.payload.inventory[0].device_id, "AA:BB:CC:DD:EE:FF");
assert.ok(shellCalls.some((call) => call.type === "open"));
assert.ok(shellCalls.some((call) => call.type === "close"));
assert.equal(shellActionResults.length, 2);
assert.ok(shellActionResults.every((result) => result.body.ok === true));
assert.ok(
shellEventPosts.some((request) =>
request.body.events.some((event) => event.type === "OPENED")
)
);
assert.ok(
shellEventPosts.some((request) =>
request.body.events.some((event) => event.type === "OUTPUT")
)
);
assert.ok(
shellEventPosts.some((request) =>
request.body.events.some((event) => event.type === "CLOSED")
)
);
assert.ok(
heartbeats.some(
(heartbeat) =>
heartbeat.body.metadata.system_metrics &&
typeof heartbeat.body.metadata.system_metrics.latency_ms === "number"
)
);
await agent.stop();
await rm(tempDir, { recursive: true, force: true });
});
test("status helpers report config without exposing the agent token", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-status-"));
const configPath = path.join(tempDir, "config.json");
await writeFile(configPath, JSON.stringify({
apiUrl: "https://api.example.test",
installToken: "install-token",
gatewayId: 42,
agentToken: "agent-token",
installedVersion: "1.2.3",
targetVersion: "1.2.4",
releaseChannel: "stable",
heartbeatIntervalSeconds: 30,
}));
const report = await getAgentStatus(configPath);
const built = buildStatusReport(configPath, {
apiUrl: "https://api.example.test",
installToken: "install-token",
gatewayId: 42,
agentToken: "agent-token",
});
assert.equal(report.command, "status");
assert.equal(report.configPath, configPath);
assert.equal(report.claimed, true);
assert.equal(report.state, "CLAIMED");
assert.equal(report.gatewayId, 42);
assert.equal(report.transport, "API_POLLING");
assert.equal(report.hasInstallToken, true);
assert.equal("agentToken" in report, false);
assert.equal(built.state, "CLAIMED");
assert.equal(built.transport, "API_POLLING");
assert.equal("agentToken" in built, false);
await rm(tempDir, { recursive: true, force: true });
});
test("parseCliArgs understands the status command and config flag", () => {
assert.deepEqual(
parseCliArgs(["status", "--config", "/tmp/config.json"]),
{ command: "status", configPath: "/tmp/config.json" }
);
assert.deepEqual(
parseCliArgs(["--config", "/tmp/config.json"]),
{ command: "start", configPath: "/tmp/config.json" }
);
});
test("runCli emits a status report for the status command", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-cli-"));
const configPath = path.join(tempDir, "config.json");
await writeFile(configPath, JSON.stringify({
apiUrl: "https://api.example.test",
installToken: "install-token",
}));
let stdout = "";
const originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = ((chunk, encoding, callback) => {
stdout += String(chunk);
if (typeof encoding === "function") {
encoding();
} else if (typeof callback === "function") {
callback();
}
return true;
});
try {
const report = await runCli(["status", "--config", configPath]);
assert.equal(report.state, "PENDING_CLAIM");
const parsed = JSON.parse(stdout);
assert.equal(parsed.command, "status");
assert.equal(parsed.claimed, false);
assert.equal(parsed.transport, "API_POLLING");
assert.equal("brokerWsUrl" in parsed, false);
} finally {
process.stdout.write = originalWrite;
}
await rm(tempDir, { recursive: true, force: true });
});
test("agent CLI status command prints the report", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-process-"));
const configPath = path.join(tempDir, "config.json");
await writeFile(configPath, JSON.stringify({
apiUrl: "https://api.example.test",
installToken: "install-token",
gatewayId: 7,
agentToken: "secret-token",
}));
const { stdout } = await execFile(
process.execPath,
[agentEntryPath, "status", "--config", configPath],
{ windowsHide: true, encoding: "utf8" }
);
const parsed = JSON.parse(stdout);
assert.equal(parsed.command, "status");
assert.equal(parsed.gatewayId, 7);
assert.equal(parsed.claimed, true);
assert.equal(parsed.transport, "API_POLLING");
assert.equal("agentToken" in parsed, false);
await rm(tempDir, { recursive: true, force: true });
});