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:
Vendored
+220
-44
@@ -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
@@ -3,6 +3,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"node-pty": "^1.1.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+17
@@ -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",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-pty": "^1.1.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user