364 lines
13 KiB
JavaScript
364 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
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 chromePath = process.env.GOCHAT_SMOKE_CHROME || '/usr/bin/google-chrome';
|
|
const frontendBaseURL = `http://${frontendHost}:${frontendPort}`;
|
|
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 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 `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>GoChat Smoke</title>
|
|
<script>
|
|
history.replaceState({}, '', ${JSON.stringify(route)});
|
|
window.chatwootConfig = ${JSON.stringify(config)};
|
|
window.globalConfig = ${JSON.stringify(globalConfig)};
|
|
window.browserConfig = { browser_name: 'chrome' };
|
|
window.errorLoggingConfig = '';
|
|
window.analyticsConfig = { token: '' };
|
|
</script>
|
|
<script type="module" src="/app/javascript/entrypoints/${entrypoint}.js"></script>
|
|
</head>
|
|
<body class="text-slate-600"><div id="app"></div></body>
|
|
</html>`;
|
|
}
|
|
|
|
const tmpHTMLDir = path.join(chatwootDir, 'tmp');
|
|
mkdirSync(tmpHTMLDir, { recursive: true });
|
|
|
|
function writeSmokeShell(name, entrypoint, route) {
|
|
writeFileSync(path.join(tmpHTMLDir, `${name}.html`), smokeHTML(entrypoint, route));
|
|
return `${frontendBaseURL}/tmp/${name}.html`;
|
|
}
|
|
|
|
const smokePages = {
|
|
login: writeSmokeShell('gochat-smoke-login', 'v3app', '/app/login'),
|
|
dashboard: writeSmokeShell('gochat-smoke-dashboard', 'dashboard', `/app/accounts/${seed.account_id}/dashboard`),
|
|
};
|
|
|
|
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: '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();
|
|
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('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: 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}`);
|
|
}
|
|
|
|
waitForRequest(substring, label, timeout = 30000) {
|
|
return this.waitFor(
|
|
`performance.getEntriesByType('resource').some(entry => entry.name.includes(${JSON.stringify(substring)}))`,
|
|
label,
|
|
timeout
|
|
);
|
|
}
|
|
|
|
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 page = await launchChrome();
|
|
try {
|
|
await page.navigate(smokePages.login);
|
|
await page.waitFor('!!document.querySelector("input[name=email_address]")', 'login email input visible');
|
|
await page.eval(`(() => {
|
|
const email = document.querySelector('input[name=email_address]');
|
|
const password = document.querySelector('input[name=password]');
|
|
email.value = ${JSON.stringify(seed.admin_email)};
|
|
password.value = ${JSON.stringify(seed.admin_password)};
|
|
email.dispatchEvent(new Event('input', { bubbles: true }));
|
|
password.dispatchEvent(new Event('input', { bubbles: true }));
|
|
document.querySelector('[data-testid=submit_button], button[type=submit]').click();
|
|
return true;
|
|
})()`);
|
|
await page.waitFor('document.cookie.includes("cw_d_session_info")', 'login stores Chatwoot auth cookie');
|
|
await page.waitFor(
|
|
`performance.getEntriesByType('resource').some(entry => entry.name.includes('/auth/sign_in'))`,
|
|
'login calls auth/sign_in'
|
|
);
|
|
|
|
await page.navigate(smokePages.dashboard);
|
|
await page.waitFor('document.querySelector("#app") && document.querySelector("#app").children.length > 0', 'dashboard app mounted');
|
|
await page.waitForRequest('/auth/validate_token', 'dashboard validates auth token');
|
|
await page.waitForRequest(`/api/v1/accounts/${seed.account_id}/conversations`, 'dashboard requests conversations');
|
|
|
|
if (enterpriseMode) {
|
|
for (const enterprisePage of enterprisePages) {
|
|
await page.navigate(enterprisePage.url);
|
|
await page.waitFor(
|
|
'document.querySelector("#app") && document.querySelector("#app").children.length > 0',
|
|
`${enterprisePage.label} app mounted`
|
|
);
|
|
for (const request of enterprisePage.requests) {
|
|
await page.waitForRequest(request, `${enterprisePage.label} requests ${request}`);
|
|
}
|
|
}
|
|
await page.eval(`fetch(${JSON.stringify(`${apiBaseURL}/api/v1/accounts/${seed.account_id}/captain/copilot_threads`)}, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
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.waitForRequest('/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();
|
|
}
|
|
}
|
|
|
|
main().catch(error => {
|
|
console.error(error.message);
|
|
process.exit(1);
|
|
});
|