Add unit tests for invoicing, orders normalization, gateway commands, and department complaints. Update schema bootstraps and improve agent command execution logic.

This commit is contained in:
Jeppe Bundgaard
2026-04-09 12:24:22 +02:00
parent 9e9372db05
commit 22f856c62a
35 changed files with 2346 additions and 167 deletions
+220 -44
View File
@@ -1,23 +1,22 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import { spawn } from "node:child_process";
import process from "node:process";
import WebSocket from "ws";
const DEFAULT_VERSION = "0.1.0";
const DEFAULT_RECONNECT_DELAY_MS = 5000;
const DEFAULT_SHELL_COLS = 120;
const DEFAULT_SHELL_ROWS = 32;
function buildBrokerHeartbeatState(isConnected) {
return isConnected
? {
status: "ONLINE",
discovery_status: "READY",
metadata: { broker_connected: true },
}
: {
status: "DEGRADED",
discovery_status: "BROKER_DISCONNECTED",
metadata: { broker_connected: false },
};
}
@@ -219,52 +218,123 @@ function defaultShellCommand() {
return { command: process.env.SHELL || "/bin/sh", args: [] };
}
export function createShellBridge(sendMessage) {
function normalizeShellSize(value, fallback) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? Math.round(numeric) : fallback;
}
async function createDefaultPtyProcess(options = {}) {
const nodePtyModule = await import("node-pty");
const spawnPty =
nodePtyModule.spawn ??
nodePtyModule.default?.spawn ??
nodePtyModule.default;
if (typeof spawnPty !== "function") {
throw new Error("node-pty spawn is unavailable");
}
return spawnPty(options.command, options.args || [], {
name: options.env?.TERM || "xterm-256color",
cols: normalizeShellSize(options.cols, DEFAULT_SHELL_COLS),
rows: normalizeShellSize(options.rows, DEFAULT_SHELL_ROWS),
cwd: options.cwd || process.cwd(),
env: options.env || process.env,
});
}
export function createShellBridge(sendMessage, { createPtyProcess = createDefaultPtyProcess } = {}) {
const sessions = new Map();
const open = (payload = {}) => {
const sessionId = String(payload.sessionId);
const shell = payload.shellCommand ? { command: payload.shellCommand, args: payload.shellArgs || [] } : defaultShellCommand();
const proc = spawn(shell.command, shell.args, {
cwd: payload.cwd || process.cwd(),
env: process.env,
stdio: "pipe",
});
const open = async (payload = {}) => {
const sessionId = String(payload.sessionId || "");
if (sessionId === "") {
return;
}
proc.stdout.on("data", (chunk) => {
sendMessage({ type: "SHELL_OUTPUT", sessionId, data: chunk.toString("utf8") });
});
proc.stderr.on("data", (chunk) => {
sendMessage({ type: "SHELL_OUTPUT", sessionId, data: chunk.toString("utf8") });
});
proc.on("close", (code) => {
const existingSession = sessions.get(sessionId);
if (existingSession) {
existingSession.pty.kill();
sessions.delete(sessionId);
sendMessage({ type: "SHELL_EXIT", sessionId, code });
});
}
sessions.set(sessionId, proc);
sendMessage({ type: "SHELL_OPENED", sessionId });
const shell = payload.shellCommand
? { command: payload.shellCommand, args: payload.shellArgs || [] }
: defaultShellCommand();
try {
const pty = await Promise.resolve(createPtyProcess({
command: shell.command,
args: shell.args,
cwd: payload.cwd || process.cwd(),
cols: normalizeShellSize(payload.cols, DEFAULT_SHELL_COLS),
rows: normalizeShellSize(payload.rows, DEFAULT_SHELL_ROWS),
env: {
...process.env,
TERM: payload.term || process.env.TERM || "xterm-256color",
},
}));
const sessionRecord = {
pty,
dataSubscription: null,
exitSubscription: null,
};
sessionRecord.dataSubscription = pty.onData((data) => {
sendMessage({ type: "SHELL_OUTPUT", sessionId, data: String(data || "") });
});
sessionRecord.exitSubscription = pty.onExit(({ exitCode }) => {
sessions.delete(sessionId);
sessionRecord.dataSubscription?.dispose?.();
sessionRecord.exitSubscription?.dispose?.();
sendMessage({ type: "SHELL_EXIT", sessionId, code: Number.isFinite(exitCode) ? exitCode : 0 });
});
sessions.set(sessionId, sessionRecord);
sendMessage({ type: "SHELL_OPENED", sessionId });
} catch (error) {
sendMessage({
type: "SHELL_OUTPUT",
sessionId,
data: `Failed to start root shell: ${error instanceof Error ? error.message : String(error)}\r\n`,
});
sendMessage({ type: "SHELL_EXIT", sessionId, code: 1 });
}
};
const input = (payload = {}) => {
const proc = sessions.get(String(payload.sessionId));
if (!proc) {
const sessionRecord = sessions.get(String(payload.sessionId || ""));
if (!sessionRecord) {
return;
}
proc.stdin.write(String(payload.data || ""));
sessionRecord.pty.write(String(payload.data || ""));
};
const resize = (payload = {}) => {
const sessionRecord = sessions.get(String(payload.sessionId || ""));
if (!sessionRecord) {
return;
}
sessionRecord.pty.resize(
normalizeShellSize(payload.cols, DEFAULT_SHELL_COLS),
normalizeShellSize(payload.rows, DEFAULT_SHELL_ROWS)
);
};
const close = (payload = {}) => {
const sessionId = String(payload.sessionId);
const proc = sessions.get(sessionId);
if (!proc) {
const sessionId = String(payload.sessionId || "");
const sessionRecord = sessions.get(sessionId);
if (!sessionRecord) {
return;
}
proc.kill();
sessions.delete(sessionId);
sessionRecord.pty.kill();
};
return { open, input, close };
return { open, input, resize, close };
}
export async function handleAgentCommand(command, deps = {}) {
@@ -288,18 +358,26 @@ export async function handleAgentCommand(command, deps = {}) {
}
export function buildHeartbeatPayload(config, extra = {}) {
return {
const payload = {
hostname: os.hostname(),
installed_version: config.installedVersion || DEFAULT_VERSION,
target_version: config.targetVersion || config.installedVersion || DEFAULT_VERSION,
status: extra.status || "ONLINE",
discovery_status: extra.discovery_status || "READY",
inventory: extra.inventory || [],
metadata: {
release_channel: config.releaseChannel || "stable",
...extra.metadata,
},
};
if (Object.prototype.hasOwnProperty.call(extra, "discovery_status")) {
payload.discovery_status = extra.discovery_status;
}
if (Array.isArray(extra.inventory)) {
payload.inventory = extra.inventory;
}
return payload;
}
export async function sendHeartbeat(config, fetchImpl = fetch, extra = {}) {
@@ -318,17 +396,80 @@ export async function sendHeartbeat(config, fetchImpl = fetch, extra = {}) {
);
}
export async function pollCommandJob(config, fetchImpl = fetch, waitSeconds = 20) {
if (!config.gatewayId || !config.agentToken) {
throw new Error("Gateway claim must complete before polling commands");
}
return apiRequest(
config.apiUrl,
`/edge-agent/gateways/${config.gatewayId}/commands/poll`,
"POST",
{
agent_token: config.agentToken,
wait_seconds: waitSeconds,
},
fetchImpl
);
}
export async function submitCommandJobResult(config, jobId, { ok, payload = {}, error = null } = {}, fetchImpl = fetch) {
if (!config.gatewayId || !config.agentToken) {
throw new Error("Gateway claim must complete before submitting command results");
}
return apiRequest(
config.apiUrl,
`/edge-agent/gateways/${config.gatewayId}/commands/${jobId}/result`,
"POST",
{
agent_token: config.agentToken,
ok: Boolean(ok),
payload,
error,
},
fetchImpl
);
}
export async function processPolledCommand(config, command, fetchImpl = fetch) {
const jobId = command?.id;
const commandType = command?.commandType || command?.command_type;
const payload = command?.payload || {};
if (!jobId || !commandType) {
return null;
}
try {
const result = await handleAgentCommand({ commandType, payload }, { fetchImpl });
await submitCommandJobResult(config, jobId, {
ok: true,
payload: result,
}, fetchImpl);
return { ok: true, payload: result };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await submitCommandJobResult(config, jobId, {
ok: false,
error: message,
}, fetchImpl);
return { ok: false, error: message };
}
}
export function connectBroker(
config,
{
fetchImpl = fetch,
wsFactory = (url) => new WebSocket(url),
createShellBridgeImpl = createShellBridge,
onOpen = null,
onClose = null,
onError = null,
} = {}
) {
const shell = createShellBridge((message) => {
const shell = createShellBridgeImpl((message) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
}
@@ -352,9 +493,13 @@ export function connectBroker(
const message = JSON.parse(raw.toString());
try {
if (message.type === "COMMAND") {
const normalizedPayload =
message.payload && typeof message.payload === "object" && message.payload.payload
? message.payload.payload
: (message.payload || {});
const payload = await handleAgentCommand({
commandType: message.commandType,
payload: message.payload || {},
payload: normalizedPayload,
}, { fetchImpl });
socket.send(JSON.stringify({
type: "COMMAND_RESULT",
@@ -366,19 +511,23 @@ export function connectBroker(
}
if (message.type === "OPEN_ROOT_SHELL") {
shell.open(message.payload || {});
await shell.open(message.payload || {});
} else if (message.type === "SHELL_INPUT") {
shell.input(message.payload || {});
} else if (message.type === "RESIZE_ROOT_SHELL") {
shell.resize(message.payload || {});
} else if (message.type === "CLOSE_ROOT_SHELL") {
shell.close(message.payload || {});
}
} catch (error) {
socket.send(JSON.stringify({
type: "COMMAND_RESULT",
commandId: message.commandId,
ok: false,
error: error instanceof Error ? error.message : String(error),
}));
if (message.type === "COMMAND") {
socket.send(JSON.stringify({
type: "COMMAND_RESULT",
commandId: message.commandId,
ok: false,
error: error instanceof Error ? error.message : String(error),
}));
}
}
});
@@ -394,6 +543,8 @@ export async function startAgent({ configPath, fetchImpl = fetch, wsFactory } =
config = await claimIfNeeded(config, configPath, fetchImpl);
const intervalMs = Number(config.heartbeatIntervalSeconds || 15) * 1000;
const reconnectDelayMs = Number(config.reconnectDelayMs || DEFAULT_RECONNECT_DELAY_MS);
const commandPollTimeoutSeconds = Number(config.commandPollTimeoutSeconds || 20);
const commandPollRetryDelayMs = Number(config.commandPollRetryDelayMs || 1000);
let brokerConnected = false;
let socket = null;
@@ -417,6 +568,29 @@ export async function startAgent({ configPath, fetchImpl = fetch, wsFactory } =
}, reconnectDelayMs);
};
const runCommandPollLoop = async () => {
while (!stopped) {
try {
const command = await pollCommandJob(config, fetchImpl, commandPollTimeoutSeconds);
if (stopped) {
break;
}
if (!command) {
continue;
}
await processPolledCommand(config, command, fetchImpl);
} catch {
if (stopped) {
break;
}
await new Promise((resolve) => setTimeout(resolve, commandPollRetryDelayMs));
}
}
};
const openBrokerConnection = () => {
if (stopped) {
return;
@@ -442,6 +616,7 @@ export async function startAgent({ configPath, fetchImpl = fetch, wsFactory } =
await sendBrokerAwareHeartbeat();
openBrokerConnection();
const commandPollPromise = runCommandPollLoop();
const timer = setInterval(() => {
sendBrokerAwareHeartbeat().catch(() => {});
@@ -458,6 +633,7 @@ export async function startAgent({ configPath, fetchImpl = fetch, wsFactory } =
};
return {
commandPollPromise,
get socket() {
return socket;
},
+1
View File
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"dependencies": {
"node-pty": "^1.1.0",
"ws": "^8.18.0"
}
}
+17
View File
@@ -6,9 +6,26 @@
"": {
"name": "truckwash-edge-agent",
"dependencies": {
"node-pty": "^1.1.0",
"ws": "^8.18.0"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+1
View File
@@ -6,6 +6,7 @@
"test": "node --test"
},
"dependencies": {
"node-pty": "^1.1.0",
"ws": "^8.18.0"
}
}
+163 -27
View File
@@ -9,8 +9,8 @@ import {
claimIfNeeded,
createShellBridge,
getRelayStatus,
startAgent,
setRelayState,
startAgent,
} from "../dist/agent.mjs";
test("claimIfNeeded persists claimed gateway credentials", async () => {
@@ -81,27 +81,90 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi
assert.equal(switched.on, false);
});
test("shell bridge streams child process output", async () => {
test("shell bridge proxies PTY output, input, resize, and close events", async () => {
const messages = [];
const shell = createShellBridge((message) => messages.push(message));
const createdPtys = [];
shell.open({
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: process.execPath,
shellArgs: ["-e", "process.stdin.on('data', (d) => process.stdout.write(d))"],
shellCommand: "/bin/bash",
shellArgs: ["-l"],
cols: 90,
rows: 24,
cwd: "/tmp",
});
await new Promise((resolve) => setTimeout(resolve, 50));
shell.input({ sessionId: "test-shell", data: "hello\n" });
await new Promise((resolve) => setTimeout(resolve, 50));
createdPtys[0].emitData("root@pi:~# ");
shell.input({ sessionId: "test-shell", data: "ls\r" });
shell.resize({ sessionId: "test-shell", cols: 120, rows: 40 });
shell.close({ sessionId: "test-shell" });
await new Promise((resolve) => setTimeout(resolve, 50));
assert.ok(messages.some((message) => message.type === "SHELL_OPENED"));
assert.ok(messages.some((message) => message.type === "SHELL_OUTPUT" && message.data.includes("hello")));
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 retries the broker tunnel and reports degraded heartbeats while disconnected", async () => {
test("startAgent reports broker state via metadata and executes polled commands", async () => {
class FakeSocket extends EventEmitter {
constructor(url) {
super();
@@ -129,21 +192,85 @@ test("startAgent retries the broker tunnel and reports degraded heartbeats while
agentToken: "agent-token",
heartbeatIntervalSeconds: 60,
reconnectDelayMs: 10,
commandPollTimeoutSeconds: 0,
commandPollRetryDelayMs: 5,
}));
const heartbeats = [];
const fakeFetch = async (url, options = {}) => {
heartbeats.push({
url,
body: JSON.parse(options.body ?? "{}"),
});
const resultPosts = [];
let polledCommandDelivered = false;
return {
ok: true,
async json() {
return { data: { ok: true } };
},
};
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")) {
resultPosts.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 sockets = [];
@@ -160,19 +287,28 @@ test("startAgent retries the broker tunnel and reports degraded heartbeats while
});
assert.equal(heartbeats[0].body.status, "DEGRADED");
assert.equal(heartbeats[0].body.discovery_status, "BROKER_DISCONNECTED");
assert.equal(heartbeats[0].body.metadata.broker_connected, false);
assert.equal("discovery_status" in heartbeats[0].body, false);
assert.equal(sockets.length, 1);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(resultPosts.length, 1);
assert.equal(resultPosts[0].body.ok, true);
assert.equal(Array.isArray(resultPosts[0].body.payload.inventory), true);
assert.equal(resultPosts[0].body.payload.inventory[0].device_id, "AA:BB:CC:DD:EE:FF");
sockets[0].readyState = 1;
sockets[0].emit("open");
await new Promise((resolve) => setTimeout(resolve, 5));
assert.ok(heartbeats.some((heartbeat) => heartbeat.body.discovery_status === "READY"));
assert.ok(heartbeats.some((heartbeat) => heartbeat.body.metadata.broker_connected === true));
assert.ok(heartbeats.every((heartbeat) => !("discovery_status" in heartbeat.body)));
sockets[0].emit("close");
await new Promise((resolve) => setTimeout(resolve, 25));
assert.ok(heartbeats.some((heartbeat) => heartbeat.body.discovery_status === "BROKER_DISCONNECTED"));
assert.ok(heartbeats.some((heartbeat) => heartbeat.body.status === "DEGRADED" && heartbeat.body.metadata.broker_connected === false));
assert.equal(sockets.length, 2);
agent.stop();