#!/usr/bin/env node import { spawn } from "node:child_process"; import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, writeFileSync, } from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; const root = process.env.GOCHAT_ROOT || process.cwd(); const frontendDir = process.env.FRONTEND_DIR || process.env.CHATWOOT_DIR || path.join(root, "..", "frontend"); const logDir = process.env.GOCHAT_SMOKE_LOG_DIR || path.join(root, ".tmp/frontend-smoke"); const apiHost = process.env.GOCHAT_SMOKE_API_HOST || "127.0.0.1"; const apiPort = process.env.GOCHAT_SMOKE_API_PORT || "3000"; const frontendHost = process.env.GOCHAT_SMOKE_FRONTEND_HOST || "127.0.0.1"; const frontendPort = process.env.GOCHAT_SMOKE_FRONTEND_PORT || "3036"; const shellHost = process.env.GOCHAT_SMOKE_SHELL_HOST || frontendHost; const shellPort = process.env.GOCHAT_SMOKE_SHELL_PORT || String(Number(frontendPort) + 1); const chromePath = process.env.GOCHAT_SMOKE_CHROME || "/usr/bin/google-chrome"; const viteBaseURL = `http://${frontendHost}:${frontendPort}`; const frontendBaseURL = `http://${shellHost}:${shellPort}`; const apiBaseURL = `http://${apiHost}:${apiPort}`; const enterpriseMode = process.argv.includes("--enterprise"); const deploymentEnv = process.env.GOCHAT_SMOKE_DEPLOYMENT_ENV || "cloud"; const installationName = process.env.GOCHAT_SMOKE_INSTALLATION_NAME || "GoChat"; const expectedBilling = (process.env.GOCHAT_SMOKE_EXPECT_BILLING || (deploymentEnv === "cloud" && installationName === "GoChat" ? "true" : "false")) === "true"; function isSuccessfulRequest(request, substring) { return ( request.url.includes(substring) && request.status >= 200 && request.status < 400 ); } function isFailedAPIRequest(request) { const isBackendAPI = request.url.includes(apiBaseURL); const isShellProxiedAPI = request.url.startsWith(frontendBaseURL) && (request.url.includes("/api/") || request.url.includes("/enterprise/") || request.url.includes("/public/") || request.url.includes("/auth/") || request.url.includes("/rails/")); if ((!isBackendAPI && !isShellProxiedAPI) || request.type === "Preflight") return false; const isNavigationAbort = request.type === "Document" && ["net::ERR_ABORTED", "net::ERR_FAILED"].includes(request.errorText); return request.status >= 400 || (request.status === 0 && !isNavigationAbort); } if (process.argv.includes("--self-test")) { const apiURL = `${apiBaseURL}/api/v1/profile`; const checks = [ isSuccessfulRequest({ url: apiURL, status: 200 }, "/api/v1/profile"), !isSuccessfulRequest({ url: apiURL, status: 500 }, "/api/v1/profile"), isFailedAPIRequest({ url: apiURL, status: 0, type: "Fetch", errorText: "net::ERR_FAILED", }), !isFailedAPIRequest({ url: apiURL, status: 0, type: "Document", errorText: "net::ERR_ABORTED", }), !isFailedAPIRequest({ url: `${frontendBaseURL}/favicon.ico`, status: 404, type: "Image", }), ]; if (checks.some((check) => !check)) throw new Error("browser smoke failure contract self-test failed"); console.log("browser smoke failure contract self-test: ok"); process.exit(0); } mkdirSync(logDir, { recursive: true }); const seed = JSON.parse(readFileSync(path.join(logDir, "seed.json"), "utf8")); const widgetConfig = JSON.parse( readFileSync(path.join(logDir, "widget_config.json"), "utf8"), ); const signInHeaders = readFileSync( path.join(logDir, "sign_in.headers"), "utf8", ); const report = { started_at: new Date().toISOString(), mode: enterpriseMode ? "enterprise" : "core", frontend_dir: frontendDir, frontend_base_url: frontendBaseURL, api_base_url: apiBaseURL, account_id: seed.account_id, installation_name: installationName, requests: [], console: [], checks: [], }; function smokeHTML(entrypoint, route) { const config = { apiHost: "", hostURL: frontendBaseURL, helpCenterURL: "", allowedLoginMethods: ["email"], signupEnabled: "false", isMfaEnabled: "false", enabledLanguages: [{ iso_639_1_code: "en", name: "English" }], helpUrls: {}, selectedLocale: "en", }; const globalConfig = { INSTALLATION_NAME: installationName, BRAND_NAME: "GoChat", LOGO: "/logo.png", LOGO_DARK: "", LOGO_THUMBNAIL: "/logo.png", DISABLE_USER_PROFILE_UPDATE: "false", DIRECT_UPLOADS_ENABLED: "false", MAXIMUM_FILE_UPLOAD_SIZE: "40", ACTIVE_PLATFORM_BANNERS: [], LOGOUT_REDIRECT_LINK: "/app/login", DEPLOYMENT_ENV: deploymentEnv, }; return ` GoChat Smoke
`; } function widgetSmokeHTML(route) { const websiteChannelConfig = widgetConfig.website_channel_config || {}; const contact = widgetConfig.contact || {}; const globalConfig = widgetConfig.global_config || {}; const chatwootWebChannel = { ...websiteChannelConfig, websiteToken: websiteChannelConfig.website_token, enabledLanguages: [{ iso_639_1_code: "en", name: "English" }], locale: "en", portal: null, hasAConnectedAgentBot: false, allowMessagesAfterResolved: true, disableBranding: false, }; return ` GoChat Widget Smoke
`; } const smokeShells = new Map(); function writeSmokeShell(name, entrypoint, route) { smokeShells.set(`/gochat-smoke/${name}.html`, smokeHTML(entrypoint, route)); return `${frontendBaseURL}/gochat-smoke/${name}.html`; } function writeWidgetSmokeShell(name, route) { smokeShells.set(`/gochat-smoke/${name}.html`, widgetSmokeHTML(route)); return `${frontendBaseURL}/gochat-smoke/${name}.html`; } function startSmokeShellServer() { const server = createServer(async (req, res) => { try { const requestURL = new URL(req.url || "/", frontendBaseURL); if (smokeShells.has(requestURL.pathname)) { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(smokeShells.get(requestURL.pathname)); return; } if (requestURL.pathname.startsWith("/app/")) { const entrypoint = requestURL.pathname === "/app/login" ? "v3app" : "dashboard"; res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(smokeHTML(entrypoint, requestURL.pathname)); return; } if (requestURL.pathname.startsWith("/vite-dev/")) { const nonEnglishLocaleModule = requestURL.pathname.match( /^\/vite-dev\/dashboard\/i18n\/locale\/([^/]+)\/index\.js$/, ); if (nonEnglishLocaleModule && nonEnglishLocaleModule[1] !== "en") { res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", }); res.end("export default {};"); return; } const upstream = await fetch( `${viteBaseURL}${requestURL.pathname}${requestURL.search}`, ); const headers = Object.fromEntries(upstream.headers.entries()); headers["access-control-allow-origin"] = "*"; res.writeHead(upstream.status, headers); res.end(Buffer.from(await upstream.arrayBuffer())); return; } if ( requestURL.pathname.startsWith("/api/") || requestURL.pathname.startsWith("/enterprise/") || requestURL.pathname.startsWith("/public/") || requestURL.pathname.startsWith("/auth/") || requestURL.pathname.startsWith("/rails/") ) { const chunks = []; for await (const chunk of req) chunks.push(chunk); const upstream = await fetch( `${apiBaseURL}${requestURL.pathname}${requestURL.search}`, { method: req.method, headers: req.headers, body: ["GET", "HEAD"].includes(req.method || "GET") ? undefined : Buffer.concat(chunks), }, ); const headers = Object.fromEntries(upstream.headers.entries()); headers["access-control-allow-origin"] = "*"; res.writeHead(upstream.status, headers); res.end(Buffer.from(await upstream.arrayBuffer())); return; } if (requestURL.pathname === "/sw.js") { res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", }); res.end( 'self.addEventListener("install", event => self.skipWaiting());', ); return; } if ( requestURL.pathname === "/favicon.ico" || requestURL.pathname === "/logo.png" ) { res.writeHead(204); res.end(); return; } res.writeHead(404, { "content-type": "text/plain" }); res.end("not found"); } catch (error) { res.writeHead(502, { "content-type": "text/plain" }); res.end(error.message); } }); return new Promise((resolve, reject) => { server.once("error", reject); server.listen(Number(shellPort), shellHost, () => resolve(server)); }); } const smokePages = { dashboard: writeSmokeShell( "gochat-smoke-dashboard", "dashboard", `/app/accounts/${seed.account_id}/dashboard`, ), billing: `${frontendBaseURL}/app/accounts/${seed.account_id}/settings/billing`, widget: writeWidgetSmokeShell( "gochat-smoke-widget", `/widget?website_token=${encodeURIComponent(widgetConfig.website_channel_config?.website_token || "gochat-smoke-widget-token")}#/messages`, ), }; function headerValue(headers, name) { const needle = `${name.toLowerCase()}:`; const line = headers .split(/\r?\n/) .find((header) => header.toLowerCase().startsWith(needle)); return line ? line.slice(line.indexOf(":") + 1).trim() : ""; } const sessionCookie = JSON.stringify({ "access-token": headerValue(signInHeaders, "access-token"), client: headerValue(signInHeaders, "client"), uid: headerValue(signInHeaders, "uid"), "token-type": headerValue(signInHeaders, "token-type") || "Bearer", }); const enterprisePages = [ { label: "SLA reports screen", name: "gochat-smoke-enterprise-sla-reports", route: `/app/accounts/${seed.account_id}/reports/sla`, requests: ["/applied_slas", "/applied_slas/metrics"], }, { label: "CSAT reports screen", name: "gochat-smoke-enterprise-csat-reports", route: `/app/accounts/${seed.account_id}/reports/csat`, requests: ["/csat_survey_responses", "/csat_survey_responses/metrics"], }, { label: "automation rules screen", name: "gochat-smoke-enterprise-automation", route: `/app/accounts/${seed.account_id}/settings/automation/list`, requests: ["/automation_rules"], }, { label: "macros screen", name: "gochat-smoke-enterprise-macros", route: `/app/accounts/${seed.account_id}/settings/macros`, requests: ["/macros"], }, { label: "audit logs screen", name: "gochat-smoke-enterprise-audit-logs", route: `/app/accounts/${seed.account_id}/settings/audit-logs/list`, requests: ["/audit_logs"], }, { label: "custom roles screen", name: "gochat-smoke-enterprise-custom-roles", route: `/app/accounts/${seed.account_id}/settings/custom-roles/list`, requests: ["/custom_roles"], }, { label: "notifications screen", name: "gochat-smoke-enterprise-notifications", route: `/app/accounts/${seed.account_id}/notifications`, requests: [`/api/v1/accounts/${seed.account_id}/notifications?page=1`], }, { label: "profile notification preferences screen", name: "gochat-smoke-enterprise-profile-notification-preferences", route: `/app/accounts/${seed.account_id}/profile/settings`, requests: [ `/api/v1/accounts/${seed.account_id}/notification_settings`, "/api/v1/profile/sessions", ], }, { label: "agent capacity screen", name: "gochat-smoke-enterprise-agent-capacity", route: `/app/accounts/${seed.account_id}/settings/assignment-policy/capacity`, requests: ["/agent_capacity_policies"], }, { label: "agent capacity edit screen", name: "gochat-smoke-enterprise-agent-capacity-edit", route: `/app/accounts/${seed.account_id}/settings/assignment-policy/capacity/edit/${seed.capacity_policy_id}`, requests: [ `/agent_capacity_policies/${seed.capacity_policy_id}`, `/agent_capacity_policies/${seed.capacity_policy_id}/users`, ], }, { label: "Captain settings screen", name: "gochat-smoke-enterprise-captain-settings", route: `/app/accounts/${seed.account_id}/settings/captain`, requests: ["/captain/preferences"], }, { label: "Captain assistant overview screen", name: "gochat-smoke-enterprise-captain-overview", route: `/app/accounts/${seed.account_id}/captain/${seed.captain_assistant_id}/overview`, requests: [ `/captain/assistants/${seed.captain_assistant_id}/stats`, `/captain/assistants/${seed.captain_assistant_id}/summary`, ], }, { label: "billing screen", name: "gochat-smoke-enterprise-billing", route: `/app/accounts/${seed.account_id}/settings/billing`, requests: [ `/enterprise/api/v1/accounts/${seed.account_id}/subscription`, `/enterprise/api/v1/accounts/${seed.account_id}/limits`, ], }, { label: "voice inbox settings screen", name: "gochat-smoke-enterprise-voice-inbox", route: `/app/accounts/${seed.account_id}/settings/inboxes/${seed.voice_inbox_id}/voice-configuration`, requests: [`/api/v1/accounts/${seed.account_id}/inboxes`], }, ].map((page) => ({ ...page, url: writeSmokeShell(page.name, "dashboard", page.route), })); class CDPPage { constructor(ws, chrome) { this.ws = ws; this.chrome = chrome; this.nextID = 1; this.pending = new Map(); this.listeners = new Map(); this.requestURLs = new Map(); ws.onmessage = (event) => this.handleMessage(JSON.parse(event.data)); } handleMessage(message) { if (message.id && this.pending.has(message.id)) { const { resolve, reject } = this.pending.get(message.id); this.pending.delete(message.id); if (message.error) reject(new Error(message.error.message)); else resolve(message.result || {}); return; } const handlers = this.listeners.get(message.method) || []; handlers.forEach((handler) => handler(message.params || {})); } send(method, params = {}) { const id = this.nextID++; this.ws.send(JSON.stringify({ id, method, params })); return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`CDP command timed out: ${method}`)); } }, 30000); }); } on(method, handler) { const handlers = this.listeners.get(method) || []; handlers.push(handler); this.listeners.set(method, handlers); } async init() { await this.send("Page.enable"); await this.send("Runtime.enable"); await this.send("Network.enable"); this.on("Network.requestWillBeSent", (params) => { if (params.requestId && params.request?.url) { this.requestURLs.set(params.requestId, params.request.url); } }); this.on("Runtime.consoleAPICalled", (params) => { report.console.push({ type: params.type, text: (params.args || []) .map((arg) => arg.value || arg.description || "") .join(" "), }); }); this.on("Runtime.exceptionThrown", (params) => { report.console.push({ type: "exception", text: params.exceptionDetails?.text || "exception", }); }); this.on("Network.responseReceived", (params) => { report.requests.push({ url: params.response.url, status: params.response.status, type: params.type, }); }); this.on("Network.loadingFailed", (params) => { report.requests.push({ url: this.requestURLs.get(params.requestId) || params.requestId, status: 0, type: params.type, errorText: params.errorText, }); }); } async navigate(url) { const loaded = new Promise((resolve) => this.on("Page.loadEventFired", resolve), ); await this.send("Page.navigate", { url }); await loaded; } async eval(expression) { const result = await this.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true, }); if (result.exceptionDetails) { throw new Error( result.exceptionDetails.text || "Runtime.evaluate failed", ); } return result.result?.value; } async waitFor(expression, label, timeout = 30000) { const start = Date.now(); while (Date.now() - start < timeout) { if (await this.eval(expression)) { report.checks.push({ label, status: "passed" }); return; } await new Promise((resolve) => setTimeout(resolve, 250)); } throw new Error(`Timed out waiting for ${label}`); } async waitForAppMounted(label, timeout = 90000) { try { await this.waitFor( 'document.querySelector("#app") && document.querySelector("#app").children.length > 0', label, timeout, ); } catch (error) { const state = await this.eval(`JSON.stringify({ href: location.href, readyState: document.readyState, appHTMLLength: document.querySelector('#app')?.innerHTML?.length || 0, appChildCount: document.querySelector('#app')?.children?.length || 0, title: document.title, })`); report.console.push({ type: "diagnostic", text: `${label}: ${state}` }); throw error; } } waitForRequest(substring, label, timeout = 30000) { return this.waitFor( `performance.getEntriesByType('resource').some(entry => entry.name.includes(${JSON.stringify(substring)}))`, label, timeout, ); } async waitForSuccessfulRequest(substring, label, timeout = 30000) { return this.waitForSuccessfulRequestAfter(substring, 0, label, timeout); } async waitForSuccessfulRequestAfter( substring, requestIndex, label, timeout = 30000, ) { const start = Date.now(); while (Date.now() - start < timeout) { if ( report.requests .slice(requestIndex) .some((request) => isSuccessfulRequest(request, substring)) ) { report.checks.push({ label, status: "passed" }); return; } await new Promise((resolve) => setTimeout(resolve, 250)); } throw new Error(`Timed out waiting for ${label}`); } assertNoFailedAPIRequests(requestIndex = 0) { const failures = report.requests .slice(requestIndex) .filter(isFailedAPIRequest); if (failures.length > 0) { throw new Error( `Frontend API requests failed: ${failures.map((request) => `${request.status} ${request.url}`).join("; ")}`, ); } report.checks.push({ label: "no failed frontend API requests", status: "passed", }); } async close() { this.ws.close(); this.chrome.kill("SIGTERM"); } } async function launchChrome() { if (typeof WebSocket !== "function") { throw new Error( "Node.js WebSocket API is unavailable; use --experimental-websocket", ); } const userDataDir = mkdtempSync(path.join(tmpdir(), "gochat-chrome-")); const args = [ "--headless=new", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--disable-dev-shm-usage", "--enable-logging=stderr", "--remote-debugging-port=0", `--user-data-dir=${userDataDir}`, ]; if (process.env.CI) args.push("--no-sandbox"); args.push("about:blank"); const chromeLogPath = path.join(logDir, "chrome.log"); const chromeLog = openSync(chromeLogPath, "w"); const chrome = spawn(chromePath, args, { stdio: ["ignore", "ignore", chromeLog], }); closeSync(chromeLog); let chromeFailure = ""; chrome.once("error", (error) => { chromeFailure = `failed to launch Chrome: ${error.message}`; }); chrome.once("exit", (code, signal) => { chromeFailure = `Chrome exited before DevTools was ready (code=${code}, signal=${signal})`; }); const portFile = path.join(userDataDir, "DevToolsActivePort"); for (let i = 0; i < 80; i += 1) { try { const [port] = readFileSync(portFile, "utf8").trim().split("\n"); const target = await fetch( `http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" }, ).then((r) => r.json()); const ws = new WebSocket(target.webSocketDebuggerUrl); await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; }); const page = new CDPPage(ws, chrome); await page.init(); return page; } catch { if (chromeFailure) break; await new Promise((resolve) => setTimeout(resolve, 250)); } } chrome.kill("SIGTERM"); throw new Error( `${chromeFailure || "Chrome DevTools did not become ready"}; see ${chromeLogPath}`, ); } async function main() { const shellServer = await startSmokeShellServer(); const page = await launchChrome(); try { await page.send("Network.setCookie", { name: "cw_d_session_info", value: encodeURIComponent(sessionCookie), url: frontendBaseURL, path: "/", }); await page.navigate(smokePages.dashboard); await page.waitForAppMounted("dashboard app mounted"); await page.waitForSuccessfulRequest( "/auth/validate_token", "dashboard validates auth token", ); await page.waitForSuccessfulRequest( `/api/v1/accounts/${seed.account_id}/conversations`, "dashboard requests conversations", ); page.assertNoFailedAPIRequests(); const billingLinkVisible = await page.eval( `Boolean(document.querySelector('a[href*="/settings/billing"]'))`, ); if (billingLinkVisible !== expectedBilling) { throw new Error( `Billing sidebar visibility mismatch: expected ${expectedBilling}, got ${billingLinkVisible}`, ); } report.checks.push({ label: `Billing sidebar visibility (${deploymentEnv})`, status: "passed", }); const billingRequestIndex = report.requests.length; await page.navigate(smokePages.billing); await page.waitForAppMounted("billing route app mounted"); await page.waitFor( `location.pathname.includes('/settings/billing') === ${expectedBilling}`, `Billing direct-route guard (${deploymentEnv})`, ); page.assertNoFailedAPIRequests(billingRequestIndex); await page.navigate(smokePages.dashboard); await page.waitForAppMounted("dashboard remounted after billing route"); const widgetRequestIndex = report.requests.length; await page.navigate(smokePages.widget); await page.waitForAppMounted("widget app mounted"); await page.waitForSuccessfulRequest( "/api/v1/widget/messages", "widget requests messages", ); await page.waitForSuccessfulRequest( "/api/v1/widget/inbox_members", "widget requests inbox members", ); await page.eval( `fetch('/api/v1/widget/campaigns?website_token=${encodeURIComponent(widgetConfig.website_channel_config?.website_token || "gochat-smoke-widget-token")}').then(response => response.ok)`, ); await page.waitForSuccessfulRequest( "/api/v1/widget/campaigns", "widget campaigns endpoint works", ); page.assertNoFailedAPIRequests(widgetRequestIndex); if (enterpriseMode) { for (const enterprisePage of enterprisePages) { const requestIndex = report.requests.length; await page.navigate(enterprisePage.url); await page.waitForAppMounted(`${enterprisePage.label} app mounted`); for (const request of enterprisePage.requests) { await page.waitForSuccessfulRequestAfter( request, requestIndex, `${enterprisePage.label} requests ${request}`, ); } page.assertNoFailedAPIRequests(requestIndex); } const copilotRequestIndex = report.requests.length; await page.eval(`fetch(${JSON.stringify(`${apiBaseURL}/api/v1/accounts/${seed.account_id}/captain/copilot_threads`)}, { method: 'POST', headers: (() => { const sessionCookie = document.cookie.split('; ').find(cookie => cookie.startsWith('cw_d_session_info=')); const session = sessionCookie ? JSON.parse(decodeURIComponent(sessionCookie.split('=').slice(1).join('='))) : {}; return { 'Content-Type': 'application/json', 'access-token': session['access-token'] || '', client: session.client || '', uid: session.uid || '', 'token-type': session['token-type'] || 'Bearer', }; })(), body: JSON.stringify({ message: 'B12 enterprise browser copilot smoke', assistant_id: ${Number(seed.captain_assistant_id)}, conversation_id: ${Number(seed.conversation_id)} }) }).then(response => response.ok)`); await page.waitForSuccessfulRequestAfter( "/captain/copilot_threads", copilotRequestIndex, "browser context requests Copilot threads", ); page.assertNoFailedAPIRequests(copilotRequestIndex); } const runtimeExceptions = report.console.filter( (entry) => entry.type === "exception", ); if (runtimeExceptions.length > 0) { throw new Error( `Browser runtime exceptions: ${runtimeExceptions.map((entry) => entry.text).join("; ")}`, ); } report.checks.push({ label: "no browser runtime exceptions", status: "passed", }); report.finished_at = new Date().toISOString(); report.status = "passed"; } catch (error) { report.finished_at = new Date().toISOString(); report.status = "failed"; report.error = error.message; throw error; } finally { writeFileSync( path.join(logDir, "browser-smoke-report.json"), JSON.stringify(report, null, 2), ); await page.close(); await new Promise((resolve) => shellServer.close(resolve)); } } main().catch((error) => { console.error(error.message); process.exit(1); });