#!/usr/bin/env node import { spawn } from 'node:child_process'; import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } 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 chatwootDir = process.env.CHATWOOT_DIR || path.join(root, 'reference/chatwoot'); 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'); 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_base_url: frontendBaseURL, api_base_url: apiBaseURL, account_id: seed.account_id, requests: [], console: [], checks: [], }; function smokeHTML(entrypoint, route) { const config = { apiHost: apiBaseURL, hostURL: frontendBaseURL, helpCenterURL: '', allowedLoginMethods: ['email'], signupEnabled: 'false', isEnterprise: 'true', isMfaEnabled: 'false', enabledLanguages: [{ iso_639_1_code: 'en', name: 'English' }], helpUrls: {}, selectedLocale: 'en', }; const globalConfig = { INSTALLATION_NAME: 'GoChat', BRAND_NAME: 'GoChat', LOGO: '/logo.png', LOGO_DARK: '', LOGO_THUMBNAIL: '/logo.png', IS_ENTERPRISE: 'true', DISABLE_USER_PROFILE_UPDATE: 'false', DIRECT_UPLOADS_ENABLED: 'false', MAXIMUM_FILE_UPLOAD_SIZE: '40', ACTIVE_PLATFORM_BANNERS: [], LOGOUT_REDIRECT_LINK: '/app/login', }; 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('/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`), 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`], }, { 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 assistants screen', name: 'gochat-smoke-enterprise-captain-assistants', route: `/app/accounts/${seed.account_id}/captain/captain_assistants_responses_index`, requests: ['/captain/assistants'], }, ].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, 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 waitForCapturedRequest(substring, label, timeout = 30000) { return this.waitForCapturedRequestAfter(substring, 0, label, timeout); } async waitForCapturedRequestAfter(substring, requestIndex, label, timeout = 30000) { const start = Date.now(); while (Date.now() - start < timeout) { if (report.requests.slice(requestIndex).some(request => request.url.includes(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(request => { const isBackendAPI = request.url.includes(apiBaseURL); const isShellProxiedAPI = request.url.startsWith(frontendBaseURL) && ( request.url.includes('/api/') || request.url.includes('/public/') || request.url.includes('/auth/') || request.url.includes('/rails/') ); if (!isBackendAPI && !isShellProxiedAPI) return false; if (request.type === 'Preflight') return false; if (request.status === 0 && ['net::ERR_ABORTED', 'net::ERR_FAILED'].includes(request.errorText)) { return false; } return request.status >= 400 || request.status === 0; }); 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() { const userDataDir = mkdtempSync(path.join(tmpdir(), 'gochat-chrome-')); const chrome = spawn(chromePath, [ '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--disable-dev-shm-usage', '--remote-debugging-port=0', `--user-data-dir=${userDataDir}`, 'about:blank', ], { stdio: ['ignore', 'ignore', 'pipe'] }); 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 { await new Promise(resolve => setTimeout(resolve, 250)); } } chrome.kill('SIGTERM'); throw new Error('Chrome DevTools did not become ready'); } 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.waitForCapturedRequest('/auth/validate_token', 'dashboard validates auth token'); await page.waitForCapturedRequest(`/api/v1/accounts/${seed.account_id}/conversations`, 'dashboard requests conversations'); page.assertNoFailedAPIRequests(); const widgetRequestIndex = report.requests.length; await page.navigate(smokePages.widget); await page.waitForAppMounted('widget app mounted'); await page.waitForCapturedRequest('/api/v1/widget/messages', 'widget requests messages'); await page.waitForCapturedRequest('/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.waitForCapturedRequest('/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.waitForCapturedRequestAfter(request, requestIndex, `${enterprisePage.label} requests ${request}`); } } 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.waitForCapturedRequest('/captain/copilot_threads', 'browser context requests Copilot threads'); } 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); });