821 lines
27 KiB
JavaScript
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);
|
|
});
|