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", "php2", "php3", "php4", "php5", "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 isLocalHost(hostname) { const normalized = String(hostname || "").toLowerCase().replace(/^\x5b|\x5d$/g, ""); return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; } function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) { const websocketUrl = new URL(String(rawUrl)); const apiUrl = new URL(normalizeBaseUrl(apiBaseUrl)); const ciBrokerPort = String(process.env.EDGE_BROKER_CI_PORT || "").trim(); if (isLocalHost(apiUrl.hostname) && websocketUrl.hostname === "edge-broker" && ciBrokerPort !== "") { websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:"; websocketUrl.hostname = apiUrl.hostname; websocketUrl.port = ciBrokerPort; websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/"; return websocketUrl.toString(); } if (apiUrl.hostname === "caddy" && websocketUrl.hostname === "caddy") { websocketUrl.hostname = "edge-broker"; websocketUrl.port = "4300"; websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/"; } if (isLocalHost(apiUrl.hostname) && ["caddy", "edge-broker"].includes(websocketUrl.hostname)) { const brokerPath = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/"; websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:"; websocketUrl.hostname = apiUrl.hostname; websocketUrl.port = apiUrl.port; websocketUrl.pathname = `/api/edge-broker${brokerPath}`; } if (isLocalHost(websocketUrl.hostname)) { 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; } return /already exists|already connected/i.test(stderr); } 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 shouldSkipComposeUp() { return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP || "").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 readGatewayDiagnostics({ baseUrl, authToken, gatewayId, containerName }) { const diagnostics = {}; if (gatewayId !== null && gatewayId > 0 && authToken) { try { const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, { token: authToken, }); diagnostics.gateway = { status: detail?.data?.status ?? null, channelStatus: detail?.data?.channel_status ?? null, brokerPresence: detail?.data?.metadata?.broker_presence ?? null, brokerConnected: detail?.data?.metadata?.broker_connected ?? null, brokerLastError: detail?.data?.metadata?.broker_last_error ?? null, }; } catch (error) { diagnostics.gatewayError = error instanceof Error ? error.message : String(error); } } try { const logs = await runCommand("docker", ["logs", "--tail", "120", containerName], { allowFailure: true, }); diagnostics.containerLogs = String(`${logs.stdout || ""}${logs.stderr || ""}`).trim().split(/\r?\n/).slice(-120); } catch (error) { diagnostics.containerLogError = error instanceof Error ? error.message : String(error); } return diagnostics; } 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 { if (!shouldSkipComposeUp()) { 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.", } ); try { 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.", } ); } catch (error) { const diagnostics = await readGatewayDiagnostics({ baseUrl, authToken, gatewayId, containerName, }); throw new Error([ error instanceof Error ? error.message : String(error), `Broker diagnostics: ${JSON.stringify(diagnostics, null, 2)}`, ].join("\n")); } 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." } ); await waitForSocketMessage( streamMessages, (message) => ( message?.type === "gateway.stream.ready" && message?.connected === true ) || ( message?.type === "presence.changed" && message?.status === "connected" ), { timeoutMs: 45_000, message: "Gateway stream never observed a connected broker presence." } ); 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 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" || streamMessages.some((message) => ( message?.type === "task.updated" && Number(message?.operationId || 0) === operationId && message?.operation?.status === "COMPLETED" )); }, { timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." } ); } catch (error) { let operationSnapshot; 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 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." } ); await waitForCondition( async () => { 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 || "")) : []; const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []); return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")) && timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"); }, { timeoutMs: 30_000, message: "Gateway logs page did not persist the shell transcript and 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(() => {}); const fixtureAuthToken = fixture?.auth_token; if (gatewayId !== null && fixtureAuthToken) { await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, { token: String(fixtureAuthToken), }).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; });