Files
gochat/backend/scripts/parity_frontend_browser_smoke.mjs
T

821 lines
27 KiB
JavaScript

#!/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 `<!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.__GOCHAT_CONFIG__ = ${JSON.stringify({ ...config, globalConfig })};
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="${viteBaseURL}/app/javascript/entrypoints/${entrypoint}.js"></script>
</head>
<body class="text-slate-600"><div id="app"></div></body>
</html>`;
}
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 `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GoChat Widget Smoke</title>
<script>
history.replaceState({}, '', ${JSON.stringify(route)});
window.chatwootWebChannel = ${JSON.stringify(chatwootWebChannel)};
window.chatwootPubsubToken = ${JSON.stringify(contact.pubsub_token || websiteChannelConfig.auth_token || "")};
window.authToken = ${JSON.stringify(websiteChannelConfig.auth_token || contact.pubsub_token || "")};
window.globalConfig = ${JSON.stringify(globalConfig)};
window.referrerURL = ${JSON.stringify(frontendBaseURL)};
window.browserConfig = { browser_name: 'chrome' };
window.errorLoggingConfig = '';
</script>
<script type="module" src="${viteBaseURL}/app/javascript/entrypoints/widget.js"></script>
</head>
<body class="text-slate-600"><div id="app"></div></body>
</html>`;
}
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);
});