Add Edge Gateway tests for unit, API, and integration scenarios
This commit is contained in:
@@ -0,0 +1,561 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile as execFileCallback, spawn as spawnCallback } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.mjs";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "caddy"];
|
||||
|
||||
async function runCommand(command, args, { cwd, allowFailure = false, stdio = "pipe" } = {}) {
|
||||
if (stdio === "inherit") {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawnCallback(command, args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0 || allowFailure) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code}`));
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await execFile(command, args, {
|
||||
cwd,
|
||||
windowsHide: true,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
return { stdout: result.stdout, stderr: result.stderr, code: 0 };
|
||||
} catch (error) {
|
||||
if (!allowFailure) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
stdout: error.stdout || "",
|
||||
stderr: error.stderr || "",
|
||||
code: typeof error.code === "number" ? error.code : 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url) {
|
||||
return String(url || "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function waitForCondition(predicate, { timeoutMs = 30_000, intervalMs = 500, message = "Timed out" } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async function ensureComposeServices(rootDir) {
|
||||
await runCommand("docker", ["compose", "up", "-d", ...COMPOSE_SERVICES], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForApiReady(baseUrl, attempts = 60) {
|
||||
const root = normalizeBaseUrl(baseUrl);
|
||||
let lastError = "API never responded";
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(`${root}/ping`);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastError = `Unexpected ping status ${response.status}`;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
throw new Error(`API did not become ready at ${root}/ping: ${lastError}`);
|
||||
}
|
||||
|
||||
function parseLastJsonLine(output) {
|
||||
const lines = String(output || "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
try {
|
||||
return JSON.parse(lines[index]);
|
||||
} catch {
|
||||
// Continue scanning backwards for the JSON payload.
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to parse JSON from command output:\n${output}`);
|
||||
}
|
||||
|
||||
async function runPhpFixture(rootDir, action, payload = null) {
|
||||
const encodedPayload = payload === null
|
||||
? ""
|
||||
: ` ${Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")}`;
|
||||
const command = `cd /var/www/html && CONFIG_DB_TARGET=debug php tests/Support/EdgeGatewayE2eFixture.php ${action}${encodedPayload}`;
|
||||
|
||||
const result = await runCommand("docker", [
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"php1",
|
||||
"sh",
|
||||
"-lc",
|
||||
command,
|
||||
], {
|
||||
cwd: rootDir,
|
||||
});
|
||||
|
||||
return parseLastJsonLine(result.stdout);
|
||||
}
|
||||
|
||||
async function apiRequest(baseUrl, method, endpoint, { token = null, body = null, headers = {} } = {}) {
|
||||
const response = await fetch(`${normalizeBaseUrl(baseUrl)}${endpoint}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(body === null ? {} : { "content-type": "application/json" }),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body === null ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
let json = null;
|
||||
|
||||
if (rawBody !== "") {
|
||||
try {
|
||||
json = JSON.parse(rawBody);
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
json?.data?.message ||
|
||||
json?.error ||
|
||||
rawBody ||
|
||||
`HTTP ${response.status}`;
|
||||
throw new Error(`${method} ${endpoint} failed: ${message}`);
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
async function loadWebSocketImplementation() {
|
||||
if (typeof WebSocket !== "undefined") {
|
||||
return WebSocket;
|
||||
}
|
||||
|
||||
const module = await import("ws");
|
||||
return module.default;
|
||||
}
|
||||
|
||||
function onSocket(socket, eventName, handler) {
|
||||
if (typeof socket.addEventListener === "function") {
|
||||
socket.addEventListener(eventName, (event) => {
|
||||
if (eventName === "message") {
|
||||
handler(event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
handler(event);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.on(eventName, handler);
|
||||
}
|
||||
|
||||
function collectSocketMessages(socket) {
|
||||
const messages = [];
|
||||
|
||||
onSocket(socket, "message", (payload) => {
|
||||
const text = typeof payload === "string"
|
||||
? payload
|
||||
: Buffer.isBuffer(payload)
|
||||
? payload.toString("utf8")
|
||||
: typeof payload?.toString === "function"
|
||||
? payload.toString()
|
||||
: "";
|
||||
|
||||
if (text === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
messages.push(JSON.parse(text));
|
||||
} catch {
|
||||
// Ignore non-JSON frames.
|
||||
}
|
||||
});
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
async function waitForSocketOpen(socket) {
|
||||
if (socket.readyState === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("Timed out waiting for websocket open.")), 10_000);
|
||||
|
||||
const onOpen = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
|
||||
onSocket(socket, "open", onOpen);
|
||||
onSocket(socket, "error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForSocketMessage(messages, predicate, options) {
|
||||
await waitForCondition(() => messages.some(predicate), options);
|
||||
}
|
||||
|
||||
function buildSocketUrl(wsUrl, token) {
|
||||
const url = new URL(String(wsUrl));
|
||||
url.searchParams.set("token", token);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function closeSocket(socket) {
|
||||
if (!socket || typeof socket.close !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const readyState = typeof socket.readyState === "number" ? socket.readyState : null;
|
||||
if (readyState !== null && readyState >= 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.close();
|
||||
}
|
||||
|
||||
function collectMessages(rows) {
|
||||
return Array.isArray(rows)
|
||||
? rows
|
||||
.map((row) => (row && typeof row === "object" ? row.message : null))
|
||||
.filter((value) => typeof value === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const rootDir = path.resolve(path.dirname(scriptPath), "..");
|
||||
const runId = randomUUID().slice(0, 8);
|
||||
const containerName = `truckwash-edge-e2e-${runId}`;
|
||||
const configDir = path.join(rootDir, ".tmp", "edge-gateway-e2e", runId);
|
||||
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
|
||||
const baseUrl = process.env.EDGE_GATEWAY_E2E_BASE_URL || DEFAULT_HOST_API_URL;
|
||||
|
||||
let fixture = null;
|
||||
let gatewayId = null;
|
||||
let streamSocket = null;
|
||||
let shellSocket = null;
|
||||
|
||||
try {
|
||||
await ensureComposeServices(rootDir);
|
||||
await waitForApiReady(baseUrl);
|
||||
|
||||
fixture = await runPhpFixture(rootDir, "create");
|
||||
const authToken = String(fixture.auth_token || "");
|
||||
const departmentId = Number(fixture.department_id || 0);
|
||||
|
||||
assert.ok(authToken !== "", "Fixture helper did not return an auth token.");
|
||||
assert.ok(departmentId > 0, "Fixture helper did not return a department id.");
|
||||
|
||||
const installTokenResponse = await apiRequest(baseUrl, "POST", "/edge-gateways/install-token", {
|
||||
token: authToken,
|
||||
body: {
|
||||
department_id: departmentId,
|
||||
label: `Edge Gateway E2E ${runId}`,
|
||||
},
|
||||
});
|
||||
const installToken = String(installTokenResponse?.data?.token || "");
|
||||
assert.ok(installToken !== "", "Install token creation did not return a token.");
|
||||
|
||||
await runCommand(process.execPath, [
|
||||
"scripts/test-gateway.mjs",
|
||||
"start",
|
||||
"--install-token",
|
||||
installToken,
|
||||
"--container-name",
|
||||
containerName,
|
||||
"--config-dir",
|
||||
configDir,
|
||||
"--heartbeat-seconds",
|
||||
"3",
|
||||
"--skip-compose-up",
|
||||
], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
try {
|
||||
await fs.access(configFilePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: `Gateway config file was not created at ${configFilePath}` }
|
||||
);
|
||||
|
||||
const config = JSON.parse(await fs.readFile(configFilePath, "utf8"));
|
||||
gatewayId = Number(config.gatewayId || 0);
|
||||
assert.ok(gatewayId > 0, "Gateway config did not include a gateway id.");
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const result = await runCommand("docker", [
|
||||
"exec",
|
||||
containerName,
|
||||
"test",
|
||||
"-f",
|
||||
"/opt/truckwash-edge-agent/runtime/last-heartbeat-ok.txt",
|
||||
], {
|
||||
allowFailure: true,
|
||||
});
|
||||
|
||||
return result.code === 0;
|
||||
},
|
||||
{
|
||||
timeoutMs: 60_000,
|
||||
message: "Gateway never wrote the successful heartbeat marker.",
|
||||
}
|
||||
);
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
return detail?.data?.status === "ONLINE"
|
||||
&& Object.keys(detail?.data?.metadata?.system_metrics || {}).length > 0;
|
||||
},
|
||||
{
|
||||
timeoutMs: 60_000,
|
||||
message: "Gateway detail never transitioned to ONLINE with fresh system metrics after install.",
|
||||
}
|
||||
);
|
||||
|
||||
const WebSocketImpl = await loadWebSocketImplementation();
|
||||
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
|
||||
token: authToken,
|
||||
body: {
|
||||
scopes: ["overview", "tasks", "logs", "statistics"],
|
||||
},
|
||||
});
|
||||
const streamWsUrl = buildSocketUrl(String(streamSession?.data?.ws_url || ""), String(streamSession?.data?.token || ""));
|
||||
streamSocket = new WebSocketImpl(streamWsUrl);
|
||||
const streamMessages = collectSocketMessages(streamSocket);
|
||||
|
||||
await waitForSocketOpen(streamSocket);
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => message?.type === "gateway.stream.ready",
|
||||
{ timeoutMs: 15_000, message: "Gateway stream never became ready." }
|
||||
);
|
||||
|
||||
const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, {
|
||||
token: authToken,
|
||||
body: {
|
||||
type: "DISCOVERY",
|
||||
request: {
|
||||
inventory: [{
|
||||
device_id: `edge-e2e-${runId}`,
|
||||
local_ip: "10.70.80.90",
|
||||
model: "TruckWash Edge E2E",
|
||||
channel_count: 1,
|
||||
online: true,
|
||||
capabilities: {
|
||||
gateway_management_v2: true,
|
||||
},
|
||||
metadata: {
|
||||
hostname: `edge-e2e-${runId}`,
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
const operationId = Number(operationResponse?.data?.operation?.id || 0);
|
||||
assert.ok(operationId > 0, "Operation creation did not return an operation id.");
|
||||
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => message?.type === "task.updated" && Number(message?.operationId || 0) === operationId,
|
||||
{ timeoutMs: 30_000, message: "Live gateway stream never emitted task.updated for the queued operation." }
|
||||
);
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
const operation = Array.isArray(operations?.data)
|
||||
? operations.data.find((item) => Number(item?.id || 0) === operationId)
|
||||
: null;
|
||||
|
||||
return operation?.status === "COMPLETED";
|
||||
},
|
||||
{ timeoutMs: 30_000, message: "Gateway operation never completed through the live agent." }
|
||||
);
|
||||
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated",
|
||||
{ timeoutMs: 15_000, message: "Live gateway stream never emitted telemetry or statistics updates." }
|
||||
);
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const logs = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
return Array.isArray(logs?.data?.log_entries) && logs.data.log_entries.length > 0;
|
||||
},
|
||||
{ timeoutMs: 20_000, message: "Gateway logs page never received live log entries from the running agent." }
|
||||
);
|
||||
|
||||
const statistics = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/statistics`, {
|
||||
token: authToken,
|
||||
});
|
||||
assert.ok(
|
||||
Object.keys(statistics?.data?.system_metrics || {}).length > 0,
|
||||
"Gateway statistics page did not expose system metrics after live telemetry."
|
||||
);
|
||||
|
||||
const shellSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/shell-sessions`, {
|
||||
token: authToken,
|
||||
body: {
|
||||
reason: "Edge gateway E2E shell validation",
|
||||
cwd: "/opt/truckwash-edge-agent",
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
},
|
||||
});
|
||||
const shellWsUrl = buildSocketUrl(String(shellSession?.data?.ws_url || ""), String(shellSession?.data?.token || ""));
|
||||
shellSocket = new WebSocketImpl(shellWsUrl);
|
||||
const shellMessages = collectSocketMessages(shellSocket);
|
||||
|
||||
await waitForSocketOpen(shellSocket);
|
||||
await waitForSocketMessage(
|
||||
shellMessages,
|
||||
(message) => message?.type === "opened",
|
||||
{ timeoutMs: 20_000, message: "Browser shell never opened against the live gateway." }
|
||||
);
|
||||
|
||||
shellSocket.send(JSON.stringify({
|
||||
type: "input",
|
||||
data: "printf 'edge-e2e-shell\\n'; exit\r",
|
||||
}));
|
||||
|
||||
await waitForSocketMessage(
|
||||
shellMessages,
|
||||
(message) => message?.type === "output" && String(message?.data || "").includes("edge-e2e-shell"),
|
||||
{ timeoutMs: 20_000, message: "Browser shell never returned the expected command output." }
|
||||
);
|
||||
|
||||
await waitForSocketMessage(
|
||||
shellMessages,
|
||||
(message) => message?.type === "closed",
|
||||
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
|
||||
);
|
||||
|
||||
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
||||
token: authToken,
|
||||
});
|
||||
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
|
||||
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
|
||||
: [];
|
||||
|
||||
assert.ok(
|
||||
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
|
||||
"Gateway logs page did not persist the shell transcript."
|
||||
);
|
||||
|
||||
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
|
||||
assert.ok(
|
||||
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"),
|
||||
"Gateway logs page did not include the shell close audit event."
|
||||
);
|
||||
|
||||
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
|
||||
} finally {
|
||||
closeSocket(shellSocket);
|
||||
closeSocket(streamSocket);
|
||||
|
||||
if (gatewayId !== null && fixture?.auth_token) {
|
||||
await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, {
|
||||
token: String(fixture.auth_token),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
await runCommand(process.execPath, [
|
||||
"scripts/test-gateway.mjs",
|
||||
"stop",
|
||||
"--container-name",
|
||||
containerName,
|
||||
], {
|
||||
cwd: rootDir,
|
||||
allowFailure: true,
|
||||
}).catch(() => {});
|
||||
|
||||
if (fixture !== null) {
|
||||
await runPhpFixture(rootDir, "cleanup", fixture).catch(() => {});
|
||||
}
|
||||
|
||||
await fs.rm(configDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user