907 lines
27 KiB
JavaScript
907 lines
27 KiB
JavaScript
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"];
|
|
|
|
function composeArgs(projectName, args) {
|
|
return ["compose", "-p", projectName, ...args];
|
|
}
|
|
|
|
async function resolveRootDir(scriptPath) {
|
|
const cwd = process.cwd();
|
|
|
|
try {
|
|
await fs.access(path.join(cwd, "docker-compose.yml"));
|
|
return cwd;
|
|
} catch {
|
|
return path.resolve(path.dirname(scriptPath), "..");
|
|
}
|
|
}
|
|
|
|
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(/\/+$/, "");
|
|
}
|
|
|
|
function baseUrlWithHost(baseUrl, host, port = null) {
|
|
const url = new URL(normalizeBaseUrl(baseUrl));
|
|
url.hostname = host;
|
|
if (port !== null) {
|
|
url.port = port;
|
|
}
|
|
return normalizeBaseUrl(url.toString());
|
|
}
|
|
|
|
function directCaddyBaseUrl(baseUrl) {
|
|
const url = new URL(normalizeBaseUrl(baseUrl));
|
|
url.hostname = "caddy";
|
|
url.port = "";
|
|
if (url.pathname === "/api" || url.pathname === "/api/") {
|
|
url.pathname = "/";
|
|
}
|
|
return normalizeBaseUrl(url.toString());
|
|
}
|
|
|
|
function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
|
|
const websocketUrl = new URL(String(rawUrl));
|
|
const apiUrl = new URL(normalizeBaseUrl(apiBaseUrl));
|
|
|
|
if (apiUrl.hostname === "caddy" && websocketUrl.hostname === "caddy") {
|
|
websocketUrl.hostname = "edge-broker";
|
|
websocketUrl.port = "4300";
|
|
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
|
}
|
|
|
|
return websocketUrl.toString();
|
|
}
|
|
|
|
async function readDefaultGatewayHost() {
|
|
if (process.platform === "win32") {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const routeTable = await fs.readFile("/proc/net/route", "utf8");
|
|
const route = routeTable
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim().split(/\s+/))
|
|
.find((fields) => fields[1] === "00000000" && /^[0-9A-Fa-f]{8}$/.test(fields[2] || ""));
|
|
|
|
if (!route) {
|
|
return null;
|
|
}
|
|
|
|
const gateway = route[2];
|
|
const octets = [
|
|
gateway.slice(6, 8),
|
|
gateway.slice(4, 6),
|
|
gateway.slice(2, 4),
|
|
gateway.slice(0, 2),
|
|
].map((octet) => Number.parseInt(octet, 16));
|
|
|
|
if (octets.some((octet) => !Number.isInteger(octet)) || octets.every((octet) => octet === 0)) {
|
|
return null;
|
|
}
|
|
|
|
return octets.join(".");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function composeNetworkName(composeProject) {
|
|
return `${composeProject}_default`;
|
|
}
|
|
|
|
async function readCurrentContainerRef() {
|
|
if (process.platform === "win32") {
|
|
return null;
|
|
}
|
|
|
|
const candidates = [];
|
|
const envHostname = String(process.env.HOSTNAME || "").trim();
|
|
if (envHostname !== "") {
|
|
candidates.push(envHostname);
|
|
}
|
|
|
|
try {
|
|
const hostname = (await fs.readFile("/etc/hostname", "utf8")).trim();
|
|
if (hostname !== "") {
|
|
candidates.push(hostname);
|
|
}
|
|
} catch {
|
|
// Not running in a container with /etc/hostname available.
|
|
}
|
|
|
|
try {
|
|
const cgroup = await fs.readFile("/proc/self/cgroup", "utf8");
|
|
const matches = cgroup.match(/[0-9a-f]{64}/gi) || [];
|
|
candidates.push(...matches);
|
|
} catch {
|
|
// cgroup metadata is optional in local development.
|
|
}
|
|
|
|
return candidates.find((candidate) => /^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,127}$/.test(candidate)) || null;
|
|
}
|
|
|
|
async function connectCurrentContainerToComposeNetwork(rootDir, composeProject) {
|
|
const containerRef = await readCurrentContainerRef();
|
|
if (!containerRef) {
|
|
return false;
|
|
}
|
|
|
|
const inspection = await runCommand("docker", ["inspect", containerRef], {
|
|
cwd: rootDir,
|
|
allowFailure: true,
|
|
});
|
|
if (inspection.code !== 0) {
|
|
return false;
|
|
}
|
|
|
|
const networkName = composeNetworkName(composeProject);
|
|
const connection = await runCommand("docker", ["network", "connect", networkName, containerRef], {
|
|
cwd: rootDir,
|
|
allowFailure: true,
|
|
});
|
|
const stderr = String(connection.stderr || "");
|
|
if (connection.code === 0) {
|
|
process.stdout.write(`Attached runner container ${containerRef} to ${networkName}.\n`);
|
|
return true;
|
|
}
|
|
|
|
if (/already exists|already connected/i.test(stderr)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function disconnectCurrentContainerFromComposeNetwork(rootDir, composeProject) {
|
|
const containerRef = await readCurrentContainerRef();
|
|
if (!containerRef) {
|
|
return;
|
|
}
|
|
|
|
await runCommand("docker", ["network", "disconnect", composeNetworkName(composeProject), containerRef], {
|
|
cwd: rootDir,
|
|
allowFailure: true,
|
|
});
|
|
}
|
|
|
|
async function readComposeServiceHost(rootDir, composeProject, serviceName) {
|
|
const ps = await runCommand("docker", composeArgs(composeProject, ["ps", "-q", serviceName]), {
|
|
cwd: rootDir,
|
|
allowFailure: true,
|
|
});
|
|
const containerId = String(ps.stdout || "").trim().split(/\s+/).find(Boolean);
|
|
const containerRefs = [
|
|
...(containerId ? [containerId] : []),
|
|
serviceName,
|
|
];
|
|
|
|
for (const containerRef of [...new Set(containerRefs)]) {
|
|
const inspection = await runCommand("docker", [
|
|
"inspect",
|
|
"-f",
|
|
"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
|
|
containerRef,
|
|
], {
|
|
cwd: rootDir,
|
|
allowFailure: true,
|
|
});
|
|
const host = String(inspection.stdout || "").trim().split(/\s+/).find((value) => /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value));
|
|
|
|
if (host) {
|
|
return host;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function candidateApiBaseUrls(baseUrl, rootDir, composeProject, useComposeNetwork = false) {
|
|
const normalized = normalizeBaseUrl(baseUrl);
|
|
const candidates = [normalized];
|
|
const url = new URL(normalized);
|
|
|
|
if (["localhost", "127.0.0.1", "::1"].includes(url.hostname)) {
|
|
if (rootDir && composeProject) {
|
|
if (useComposeNetwork) {
|
|
candidates.push(directCaddyBaseUrl(normalized));
|
|
candidates.push(baseUrlWithHost(normalized, "traefik", ""));
|
|
}
|
|
|
|
const traefikHost = await readComposeServiceHost(rootDir, composeProject, "traefik");
|
|
if (traefikHost) {
|
|
candidates.push(baseUrlWithHost(normalized, traefikHost, ""));
|
|
}
|
|
}
|
|
|
|
const gatewayHost = await readDefaultGatewayHost();
|
|
if (gatewayHost) {
|
|
candidates.push(baseUrlWithHost(normalized, gatewayHost));
|
|
}
|
|
|
|
candidates.push(baseUrlWithHost(normalized, "host.docker.internal"));
|
|
}
|
|
|
|
return [...new Set(candidates)];
|
|
}
|
|
|
|
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, composeProject) {
|
|
await runCommand("docker", composeArgs(composeProject, ["up", "-d", ...COMPOSE_SERVICES]), {
|
|
cwd: rootDir,
|
|
stdio: "inherit",
|
|
});
|
|
}
|
|
|
|
async function waitForApiReady(baseUrl, rootDir, composeProject, useComposeNetwork = false, attempts = 60) {
|
|
const candidates = await candidateApiBaseUrls(baseUrl, rootDir, composeProject, useComposeNetwork);
|
|
let lastError = "API never responded";
|
|
|
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
for (const root of candidates) {
|
|
try {
|
|
const response = await fetch(`${root}/ping`, {
|
|
signal: AbortSignal.timeout(1000),
|
|
});
|
|
if (response.ok) {
|
|
return root;
|
|
}
|
|
|
|
lastError = `${root}/ping returned HTTP ${response.status}`;
|
|
} catch (error) {
|
|
lastError = `${root}/ping failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
}
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
}
|
|
|
|
throw new Error(`API did not become ready at ${candidates.map((candidate) => `${candidate}/ping`).join(", ")}: ${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, composeProject, 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", composeArgs(composeProject, [
|
|
"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 socketUrl = typeof socket.url === "string" && socket.url !== "" ? ` (${socket.url})` : "";
|
|
|
|
const onOpen = () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
};
|
|
|
|
const onError = (error) => {
|
|
clearTimeout(timeout);
|
|
if (error instanceof Error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
|
|
const readyState = typeof socket.readyState === "number" ? socket.readyState : "unknown";
|
|
reject(new Error(`Websocket failed to open${socketUrl}; readyState=${readyState}.`));
|
|
};
|
|
|
|
const onClose = (event) => {
|
|
clearTimeout(timeout);
|
|
const code = event && typeof event === "object" && "code" in event ? event.code : "unknown";
|
|
const reason = event && typeof event === "object" && "reason" in event ? event.reason : "";
|
|
reject(new Error(`Websocket closed before open${socketUrl}; code=${code} reason=${reason || "none"}.`));
|
|
};
|
|
|
|
onSocket(socket, "open", onOpen);
|
|
onSocket(socket, "error", onError);
|
|
onSocket(socket, "close", onClose);
|
|
});
|
|
}
|
|
|
|
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 shouldCopyGatewayConfig() {
|
|
return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_COPY_CONFIG || "").trim());
|
|
}
|
|
|
|
function collectMessages(rows) {
|
|
return Array.isArray(rows)
|
|
? rows
|
|
.map((row) => (row && typeof row === "object" ? row.message : null))
|
|
.filter((value) => typeof value === "string")
|
|
: [];
|
|
}
|
|
|
|
function summarizeStreamMessages(messages, limit = 12) {
|
|
return messages
|
|
.slice(-limit)
|
|
.map((message) => {
|
|
if (!message || typeof message !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const summary = {
|
|
type: message.type || "unknown",
|
|
};
|
|
|
|
if (message.operationId !== undefined) {
|
|
summary.operationId = Number(message.operationId || 0);
|
|
}
|
|
|
|
if (message.operation && typeof message.operation === "object") {
|
|
summary.operationStatus = message.operation.status || null;
|
|
}
|
|
|
|
if (message.gateway && typeof message.gateway === "object") {
|
|
summary.gatewayStatus = message.gateway.status || null;
|
|
}
|
|
|
|
return summary;
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function main() {
|
|
const scriptPath = fileURLToPath(import.meta.url);
|
|
const rootDir = await resolveRootDir(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);
|
|
let baseUrl = process.env.EDGE_GATEWAY_E2E_BASE_URL || DEFAULT_HOST_API_URL;
|
|
const composeProject =
|
|
process.env.EDGE_GATEWAY_E2E_COMPOSE_PROJECT
|
|
|| path.basename(rootDir);
|
|
|
|
let fixture = null;
|
|
let gatewayId = null;
|
|
let streamSocket = null;
|
|
let shellSocket = null;
|
|
let runnerNetworkAttached = false;
|
|
|
|
try {
|
|
await ensureComposeServices(rootDir, composeProject);
|
|
runnerNetworkAttached = await connectCurrentContainerToComposeNetwork(rootDir, composeProject);
|
|
baseUrl = await waitForApiReady(baseUrl, rootDir, composeProject, runnerNetworkAttached);
|
|
process.stdout.write(`Using API base URL ${baseUrl}\n`);
|
|
|
|
fixture = await runPhpFixture(rootDir, composeProject, "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,
|
|
"--host-api-url",
|
|
baseUrl,
|
|
"--container-name",
|
|
containerName,
|
|
"--config-dir",
|
|
configDir,
|
|
"--heartbeat-seconds",
|
|
"3",
|
|
"--skip-compose-up",
|
|
...(shouldCopyGatewayConfig() ? ["--copy-config"] : []),
|
|
], {
|
|
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.",
|
|
}
|
|
);
|
|
|
|
await waitForCondition(
|
|
async () => {
|
|
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
|
token: authToken,
|
|
});
|
|
|
|
return Boolean(
|
|
detail?.data?.channel_status?.broker?.connected
|
|
|| detail?.data?.metadata?.broker_connected
|
|
);
|
|
},
|
|
{
|
|
timeoutMs: 90_000,
|
|
message: "Gateway never established a live broker connection 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(
|
|
resolveBrokerWebSocketUrl(String(streamSession?.data?.ws_url || ""), baseUrl),
|
|
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 readyMessage = streamMessages.find((message) => message?.type === "gateway.stream.ready");
|
|
assert.equal(
|
|
Boolean(readyMessage?.connected),
|
|
true,
|
|
"Gateway stream became ready before the broker reported the gateway as connected."
|
|
);
|
|
|
|
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.");
|
|
|
|
try {
|
|
await waitForSocketMessage(
|
|
streamMessages,
|
|
(message) => message?.type === "task.updated" && Number(message?.operationId || 0) === operationId,
|
|
{ timeoutMs: 180_000, message: "Live gateway stream never emitted task.updated for the queued operation." }
|
|
);
|
|
} catch (error) {
|
|
let operationSnapshot = null;
|
|
try {
|
|
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
|
token: authToken,
|
|
});
|
|
operationSnapshot = Array.isArray(operations?.data)
|
|
? operations.data.find((item) => Number(item?.id || 0) === operationId) || null
|
|
: null;
|
|
} catch {
|
|
operationSnapshot = null;
|
|
}
|
|
|
|
const diagnostic = [
|
|
error instanceof Error ? error.message : String(error),
|
|
`Recent stream messages: ${JSON.stringify(summarizeStreamMessages(streamMessages))}`,
|
|
`Operation snapshot: ${JSON.stringify(operationSnapshot)}`,
|
|
].join("\n");
|
|
throw new Error(diagnostic);
|
|
}
|
|
|
|
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: 180_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,
|
|
});
|
|
|
|
const timeline = Array.isArray(logs?.data?.entries)
|
|
? logs.data.entries
|
|
: (Array.isArray(logs?.data?.timeline) ? logs.data.timeline : []);
|
|
|
|
return timeline.some((entry) => {
|
|
const nestedEntry = entry?.entry && typeof entry.entry === "object" ? entry.entry : null;
|
|
const directOperationId = Number(nestedEntry?.operation_id || 0);
|
|
const contextualOperationId = Number(nestedEntry?.context?.operation_id || 0);
|
|
|
|
return directOperationId === operationId || contextualOperationId === operationId;
|
|
});
|
|
},
|
|
{ timeoutMs: 30_000, message: "Gateway logs page never reflected the live operation timeline." }
|
|
);
|
|
|
|
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(
|
|
resolveBrokerWebSocketUrl(String(shellSession?.data?.ws_url || ""), baseUrl),
|
|
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\n",
|
|
}));
|
|
|
|
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);
|
|
|
|
await runCommand(process.execPath, [
|
|
"scripts/test-gateway.mjs",
|
|
"stop",
|
|
"--container-name",
|
|
containerName,
|
|
], {
|
|
cwd: rootDir,
|
|
allowFailure: true,
|
|
}).catch(() => {});
|
|
|
|
if (gatewayId !== null && fixture?.auth_token) {
|
|
await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, {
|
|
token: String(fixture.auth_token),
|
|
}).catch(() => {});
|
|
}
|
|
|
|
if (fixture !== null) {
|
|
await runPhpFixture(rootDir, composeProject, "cleanup", fixture).catch(() => {});
|
|
}
|
|
|
|
await fs.rm(configDir, { recursive: true, force: true }).catch(() => {});
|
|
|
|
if (runnerNetworkAttached) {
|
|
await disconnectCurrentContainerFromComposeNetwork(rootDir, composeProject).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exitCode = 1;
|
|
});
|