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(/\/+$/, ""); } 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, 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, 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 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); const 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; try { await ensureComposeServices(rootDir, composeProject); await waitForApiReady(baseUrl); 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, "--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.", } ); 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(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 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(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\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(() => {}); } } main().catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; });