feat: complete current GoChat updates

This commit is contained in:
2026-09-13 00:13:55 +08:00
parent 6e62f5094f
commit 0dd188c46f
58 changed files with 2662 additions and 1503 deletions
+380 -212
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { spawn } from "node:child_process";
import {
closeSync,
mkdirSync,
@@ -7,70 +7,110 @@ import {
openSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
} 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, '..', 'docs', '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 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 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 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;
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;
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')) {
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' }),
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');
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 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',
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: [],
@@ -78,31 +118,28 @@ const report = {
function smokeHTML(entrypoint, route) {
const config = {
apiHost: '',
apiHost: "",
hostURL: frontendBaseURL,
helpCenterURL: '',
allowedLoginMethods: ['email'],
signupEnabled: 'false',
isMfaEnabled: 'false',
enabledLanguages: [{ iso_639_1_code: 'en', name: 'English' }],
helpCenterURL: "",
allowedLoginMethods: ["email"],
signupEnabled: "false",
isMfaEnabled: "false",
enabledLanguages: [{ iso_639_1_code: "en", name: "English" }],
helpUrls: {},
selectedLocale: 'en',
isEnterprise: 'true',
enterprisePlanName: 'enterprise',
selectedLocale: "en",
};
const globalConfig = {
INSTALLATION_NAME: 'GoChat',
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',
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: 'cloud',
IS_ENTERPRISE: 'true',
LOGOUT_REDIRECT_LINK: "/app/login",
DEPLOYMENT_ENV: deploymentEnv,
};
return `<!doctype html>
<html>
@@ -112,6 +149,7 @@ function smokeHTML(entrypoint, route) {
<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' };
@@ -131,8 +169,8 @@ function widgetSmokeHTML(route) {
const chatwootWebChannel = {
...websiteChannelConfig,
websiteToken: websiteChannelConfig.website_token,
enabledLanguages: [{ iso_639_1_code: 'en', name: 'English' }],
locale: 'en',
enabledLanguages: [{ iso_639_1_code: "en", name: "English" }],
locale: "en",
portal: null,
hasAConnectedAgentBot: false,
allowMessagesAfterResolved: true,
@@ -147,8 +185,8 @@ function widgetSmokeHTML(route) {
<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.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' };
@@ -175,166 +213,198 @@ function writeWidgetSmokeShell(name, route) {
function startSmokeShellServer() {
const server = createServer(async (req, res) => {
try {
const requestURL = new URL(req.url || '/', frontendBaseURL);
const requestURL = new URL(req.url || "/", frontendBaseURL);
if (smokeShells.has(requestURL.pathname)) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
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' });
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 {};');
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 upstream = await fetch(
`${viteBaseURL}${requestURL.pathname}${requestURL.search}`,
);
const headers = Object.fromEntries(upstream.headers.entries());
headers['access-control-allow-origin'] = '*';
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/')
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 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'] = '*';
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());');
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') {
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');
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found");
} catch (error) {
res.writeHead(502, { 'content-type': 'text/plain' });
res.writeHead(502, { "content-type": "text/plain" });
res.end(error.message);
}
});
return new Promise((resolve, reject) => {
server.once('error', 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`),
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 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',
"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',
label: "SLA reports screen",
name: "gochat-smoke-enterprise-sla-reports",
route: `/app/accounts/${seed.account_id}/reports/sla`,
requests: ['/applied_slas', '/applied_slas/metrics'],
requests: ["/applied_slas", "/applied_slas/metrics"],
},
{
label: 'CSAT reports screen',
name: 'gochat-smoke-enterprise-csat-reports',
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'],
requests: ["/csat_survey_responses", "/csat_survey_responses/metrics"],
},
{
label: 'automation rules screen',
name: 'gochat-smoke-enterprise-automation',
label: "automation rules screen",
name: "gochat-smoke-enterprise-automation",
route: `/app/accounts/${seed.account_id}/settings/automation/list`,
requests: ['/automation_rules'],
requests: ["/automation_rules"],
},
{
label: 'macros screen',
name: 'gochat-smoke-enterprise-macros',
label: "macros screen",
name: "gochat-smoke-enterprise-macros",
route: `/app/accounts/${seed.account_id}/settings/macros`,
requests: ['/macros'],
requests: ["/macros"],
},
{
label: 'audit logs screen',
name: 'gochat-smoke-enterprise-audit-logs',
label: "audit logs screen",
name: "gochat-smoke-enterprise-audit-logs",
route: `/app/accounts/${seed.account_id}/settings/audit-logs/list`,
requests: ['/audit_logs'],
requests: ["/audit_logs"],
},
{
label: 'custom roles screen',
name: 'gochat-smoke-enterprise-custom-roles',
label: "custom roles screen",
name: "gochat-smoke-enterprise-custom-roles",
route: `/app/accounts/${seed.account_id}/settings/custom-roles/list`,
requests: ['/custom_roles'],
requests: ["/custom_roles"],
},
{
label: 'notifications screen',
name: 'gochat-smoke-enterprise-notifications',
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',
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',
"/api/v1/profile/sessions",
],
},
{
label: 'agent capacity screen',
name: 'gochat-smoke-enterprise-agent-capacity',
label: "agent capacity screen",
name: "gochat-smoke-enterprise-agent-capacity",
route: `/app/accounts/${seed.account_id}/settings/assignment-policy/capacity`,
requests: ['/agent_capacity_policies'],
requests: ["/agent_capacity_policies"],
},
{
label: 'agent capacity edit screen',
name: 'gochat-smoke-enterprise-agent-capacity-edit',
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`],
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',
label: "Captain settings screen",
name: "gochat-smoke-enterprise-captain-settings",
route: `/app/accounts/${seed.account_id}/settings/captain`,
requests: ['/captain/preferences'],
requests: ["/captain/preferences"],
},
{
label: 'Captain assistant overview screen',
name: 'gochat-smoke-enterprise-captain-overview',
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`,
@@ -342,8 +412,8 @@ const enterprisePages = [
],
},
{
label: 'billing screen',
name: 'gochat-smoke-enterprise-billing',
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`,
@@ -351,14 +421,14 @@ const enterprisePages = [
],
},
{
label: 'voice inbox settings screen',
name: 'gochat-smoke-enterprise-voice-inbox',
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 => ({
].map((page) => ({
...page,
url: writeSmokeShell(page.name, 'dashboard', page.route),
url: writeSmokeShell(page.name, "dashboard", page.route),
}));
class CDPPage {
@@ -369,7 +439,7 @@ class CDPPage {
this.pending = new Map();
this.listeners = new Map();
this.requestURLs = new Map();
ws.onmessage = event => this.handleMessage(JSON.parse(event.data));
ws.onmessage = (event) => this.handleMessage(JSON.parse(event.data));
}
handleMessage(message) {
@@ -381,7 +451,7 @@ class CDPPage {
return;
}
const handlers = this.listeners.get(message.method) || [];
handlers.forEach(handler => handler(message.params || {}));
handlers.forEach((handler) => handler(message.params || {}));
}
send(method, params = {}) {
@@ -405,31 +475,36 @@ class CDPPage {
}
async init() {
await this.send('Page.enable');
await this.send('Runtime.enable');
await this.send('Network.enable');
this.on('Network.requestWillBeSent', params => {
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 => {
this.on("Runtime.consoleAPICalled", (params) => {
report.console.push({
type: params.type,
text: (params.args || []).map(arg => arg.value || arg.description || '').join(' '),
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("Runtime.exceptionThrown", (params) => {
report.console.push({
type: "exception",
text: params.exceptionDetails?.text || "exception",
});
});
this.on('Network.responseReceived', params => {
this.on("Network.responseReceived", (params) => {
report.requests.push({
url: params.response.url,
status: params.response.status,
type: params.type,
});
});
this.on('Network.loadingFailed', params => {
this.on("Network.loadingFailed", (params) => {
report.requests.push({
url: this.requestURLs.get(params.requestId) || params.requestId,
status: 0,
@@ -440,19 +515,23 @@ class CDPPage {
}
async navigate(url) {
const loaded = new Promise(resolve => this.on('Page.loadEventFired', resolve));
await this.send('Page.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', {
const result = await this.send("Runtime.evaluate", {
expression,
awaitPromise: true,
returnByValue: true,
});
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.text || 'Runtime.evaluate failed');
throw new Error(
result.exceptionDetails.text || "Runtime.evaluate failed",
);
}
return result.result?.value;
}
@@ -461,10 +540,10 @@ class CDPPage {
const start = Date.now();
while (Date.now() - start < timeout) {
if (await this.eval(expression)) {
report.checks.push({ label, status: 'passed' });
report.checks.push({ label, status: "passed" });
return;
}
await new Promise(resolve => setTimeout(resolve, 250));
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for ${label}`);
}
@@ -474,7 +553,7 @@ class CDPPage {
await this.waitFor(
'document.querySelector("#app") && document.querySelector("#app").children.length > 0',
label,
timeout
timeout,
);
} catch (error) {
const state = await this.eval(`JSON.stringify({
@@ -484,7 +563,7 @@ class CDPPage {
appChildCount: document.querySelector('#app')?.children?.length || 0,
title: document.title,
})`);
report.console.push({ type: 'diagnostic', text: `${label}: ${state}` });
report.console.push({ type: "diagnostic", text: `${label}: ${state}` });
throw error;
}
}
@@ -493,7 +572,7 @@ class CDPPage {
return this.waitFor(
`performance.getEntriesByType('resource').some(entry => entry.name.includes(${JSON.stringify(substring)}))`,
label,
timeout
timeout,
);
}
@@ -501,65 +580,88 @@ class CDPPage {
return this.waitForSuccessfulRequestAfter(substring, 0, label, timeout);
}
async waitForSuccessfulRequestAfter(substring, requestIndex, label, timeout = 30000) {
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' });
if (
report.requests
.slice(requestIndex)
.some((request) => isSuccessfulRequest(request, substring))
) {
report.checks.push({ label, status: "passed" });
return;
}
await new Promise(resolve => setTimeout(resolve, 250));
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);
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('; ')}`);
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' });
report.checks.push({
label: "no failed frontend API requests",
status: "passed",
});
}
async close() {
this.ws.close();
this.chrome.kill('SIGTERM');
this.chrome.kill("SIGTERM");
}
}
async function launchChrome() {
if (typeof WebSocket !== 'function') {
throw new Error('Node.js WebSocket API is unavailable; use --experimental-websocket');
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 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',
"--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] });
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 => {
let chromeFailure = "";
chrome.once("error", (error) => {
chromeFailure = `failed to launch Chrome: ${error.message}`;
});
chrome.once('exit', (code, signal) => {
chrome.once("exit", (code, signal) => {
chromeFailure = `Chrome exited before DevTools was ready (code=${code}, signal=${signal})`;
});
const portFile = path.join(userDataDir, 'DevToolsActivePort');
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 [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;
@@ -570,37 +672,80 @@ async function launchChrome() {
return page;
} catch {
if (chromeFailure) break;
await new Promise(resolve => setTimeout(resolve, 250));
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
chrome.kill('SIGTERM');
throw new Error(`${chromeFailure || 'Chrome DevTools did not become ready'}; see ${chromeLogPath}`);
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',
await page.send("Network.setCookie", {
name: "cw_d_session_info",
value: encodeURIComponent(sessionCookie),
url: frontendBaseURL,
path: '/',
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');
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');
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) {
@@ -609,7 +754,11 @@ async function main() {
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}`);
await page.waitForSuccessfulRequestAfter(
request,
requestIndex,
`${enterprisePage.label} requests ${request}`,
);
}
page.assertNoFailedAPIRequests(requestIndex);
}
@@ -629,24 +778,43 @@ async function main() {
})(),
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');
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';
report.status = "passed";
} catch (error) {
report.finished_at = new Date().toISOString();
report.status = 'failed';
report.status = "failed";
report.error = error.message;
throw error;
} finally {
writeFileSync(path.join(logDir, 'browser-smoke-report.json'), JSON.stringify(report, null, 2));
writeFileSync(
path.join(logDir, "browser-smoke-report.json"),
JSON.stringify(report, null, 2),
);
await page.close();
await new Promise(resolve => shellServer.close(resolve));
await new Promise((resolve) => shellServer.close(resolve));
}
}
main().catch(error => {
main().catch((error) => {
console.error(error.message);
process.exit(1);
});
+99 -79
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# Repeatable B12 smoke harness for running the reused Chatwoot frontend against GoChat.
# Repeatable B12 smoke harness for running the GoChat frontend against GoChat.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CHATWOOT_DIR="${CHATWOOT_DIR:-$ROOT/../docs/chatwoot}"
CHATWOOT_DIR="${CHATWOOT_DIR:-$ROOT/../frontend}"
LOG_DIR="${GOCHAT_SMOKE_LOG_DIR:-$ROOT/.tmp/frontend-smoke}"
REPORT_PATH="${GOCHAT_SMOKE_REPORT:-$ROOT/../docs/parity/frontend-smoke-report.md}"
API_HOST="${GOCHAT_SMOKE_API_HOST:-127.0.0.1}"
@@ -26,14 +26,14 @@ Modes:
--check Validate local prerequisites and write a readiness report.
--print Print the exact boot commands without starting processes.
--boot-only Start GoChat and Vite, verify /health and frontend HTTP, then exit.
--api-smoke Run seed plus Chatwoot-frontend API assertions against a running GoChat backend.
--browser-smoke Run API smoke, then drive the reused Chatwoot frontend in headless Chrome.
--api-smoke Run seed plus GoChat-frontend API assertions against a running GoChat backend.
--browser-smoke Run API smoke, then drive the GoChat frontend in headless Chrome.
--enterprise-smoke Run API smoke plus enterprise feature API assertions against GoChat.
--enterprise-browser-smoke Run enterprise smoke, then navigate reused Chatwoot enterprise screens in headless Chrome.
--enterprise-browser-smoke Run enterprise smoke, then navigate GoChat enterprise screens in headless Chrome.
--no-seed Skip seed during --api-smoke and use GOCHAT_SEED_* values already present in DB.
Environment:
CHATWOOT_DIR Chatwoot checkout path. Default: ../docs/chatwoot (4.15.1 upstream source)
CHATWOOT_DIR Frontend checkout path. Default: ../frontend
GOCHAT_SMOKE_API_PORT GoChat backend port. Default: 3000
GOCHAT_SMOKE_FRONTEND_HOST Vite frontend host. Default: localhost
GOCHAT_SMOKE_FRONTEND_PORT Vite frontend port. Default: 3036
@@ -44,6 +44,9 @@ Environment:
GOCHAT_SMOKE_MEILI_API_KEY Meilisearch API key. Default: gochat_dev
GOCHAT_SMOKE_READY_TIMEOUT_SECONDS Readiness wait timeout. Default: 180
GOCHAT_SMOKE_CHROME Chrome binary for browser smoke. Default: /usr/bin/google-chrome
GOCHAT_SMOKE_DEPLOYMENT_ENV Browser fixture deployment environment. Default: cloud
GOCHAT_SMOKE_INSTALLATION_NAME Browser fixture installation name. Default: GoChat
GOCHAT_SMOKE_EXPECT_BILLING Expected Billing visibility (true/false); defaults from cloud/custom-brand matrix.
USAGE
}
@@ -51,21 +54,28 @@ RUN_SEED="true"
for arg in "$@"; do
case "$arg" in
--check) MODE="check" ;;
--print) MODE="print" ;;
--boot-only) KEEP_ALIVE="false" ;;
--api-smoke) MODE="api-smoke" ;;
--browser-smoke) MODE="browser-smoke" ;;
--enterprise-smoke) MODE="enterprise-smoke" ;;
--enterprise-browser-smoke) MODE="enterprise-browser-smoke" ;;
--no-seed) RUN_SEED="false" ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $arg" >&2; usage; exit 2 ;;
--check) MODE="check" ;;
--print) MODE="print" ;;
--boot-only) KEEP_ALIVE="false" ;;
--api-smoke) MODE="api-smoke" ;;
--browser-smoke) MODE="browser-smoke" ;;
--enterprise-smoke) MODE="enterprise-smoke" ;;
--enterprise-browser-smoke) MODE="enterprise-browser-smoke" ;;
--no-seed) RUN_SEED="false" ;;
-h | --help)
usage
exit 0
;;
*)
echo "unknown argument: $arg" >&2
usage
exit 2
;;
esac
done
backend_cmd=(env GOCHAT_ENV=development GOCHAT_SERVER_HOST="$API_HOST" GOCHAT_SERVER_PORT="$API_PORT" GOCHAT_SERVER_MODE=debug GOCHAT_SEARCH_ENGINE="$SEARCH_ENGINE" GOCHAT_SEARCH_HOST="$MEILI_HOST" GOCHAT_SEARCH_API_KEY="$MEILI_API_KEY" go run ./cmd/gochat serve)
frontend_cmd=(env CHATWOOT_API_HOST="http://$API_HOST:$API_PORT" pnpm exec vite --host "$FRONTEND_HOST" --port "$FRONTEND_PORT")
frontend_cmd=(env VITE_API_HOST="http://$API_HOST:$API_PORT" pnpm exec vite --host "$FRONTEND_HOST" --port "$FRONTEND_PORT")
need() {
if ! command -v "$1" >/dev/null 2>&1; then
@@ -124,10 +134,11 @@ scripts/parity_frontend_smoke.sh --enterprise-browser-smoke
- Search: \`$SEARCH_ENGINE\` at \`$MEILI_HOST\`
- Log: \`$LOG_DIR/gochat.log\`
## Reused Chatwoot Frontend
## GoChat Frontend
- URL: http://$FRONTEND_HOST:$FRONTEND_PORT
- Source: \`$CHATWOOT_DIR\`
- Deployment fixture: \`${GOCHAT_SMOKE_DEPLOYMENT_ENV:-cloud}\`
- Command: \`(cd $CHATWOOT_DIR && ${frontend_cmd[*]})\`
- Log: \`$LOG_DIR/chatwoot-vite.log\`
@@ -148,7 +159,7 @@ The seed command creates deterministic login/account/inbox/contact/company/conve
| Area | Current result | Owner if failing |
| --- | --- | --- |
| Boot GoChat backend | ${BOOT_BACKEND_RESULT:-Not run in check mode} | B12.1 |
| Boot reused Chatwoot Vite frontend | ${BOOT_FRONTEND_RESULT:-Not run in check mode} | B12.1 |
| Boot GoChat Vite frontend | ${BOOT_FRONTEND_RESULT:-Not run in check mode} | B12.1 |
| Auth/profile | ${AUTH_PROFILE_RESULT:-Pending browser/API smoke} | B2/B12.2 |
| Inbox list/settings | ${INBOX_RESULT:-Pending browser/API smoke} | B5/B12.2 |
| Conversation list/detail/message send | ${CONVERSATION_RESULT:-Pending browser/API smoke} | B3/B12.2 |
@@ -173,9 +184,18 @@ check_prereqs() {
need curl
need node
need pnpm
test -d "$CHATWOOT_DIR" || { echo "missing Chatwoot checkout: $CHATWOOT_DIR" >&2; return 1; }
test -f "$CHATWOOT_DIR/package.json" || { echo "missing Chatwoot package.json" >&2; return 1; }
test -f "$ROOT/cmd/gochat/main.go" || { echo "missing GoChat server entrypoint cmd/gochat/main.go" >&2; return 1; }
test -d "$CHATWOOT_DIR" || {
echo "missing Chatwoot checkout: $CHATWOOT_DIR" >&2
return 1
}
test -f "$CHATWOOT_DIR/package.json" || {
echo "missing Chatwoot package.json" >&2
return 1
}
test -f "$ROOT/cmd/gochat/main.go" || {
echo "missing GoChat server entrypoint cmd/gochat/main.go" >&2
return 1
}
}
wait_url() {
@@ -464,40 +484,40 @@ SEED
fi
BOOT_BACKEND_RESULT="Passed /health during API smoke" \
AUTH_PROFILE_RESULT="Passed API smoke" \
INBOX_RESULT="Passed API smoke" \
CONVERSATION_RESULT="Passed API smoke" \
CRM_RESULT="Passed contact/company API smoke" \
SEARCH_RESULT="Passed search API smoke" \
WIDGET_RESULT="Passed API smoke" \
CSAT_RESULT="Passed public show API smoke" \
ENTERPRISE_RESULT="Pending B12.3 browser/API smoke" \
write_report "API smoke passed for reused Chatwoot frontend core paths; live browser smoke not run in this mode." "Command run: \`scripts/parity_frontend_smoke.sh --api-smoke\`. Logs and payload captures are under \`$LOG_DIR\`. B12.2 still needs browser navigation assertions for the reused Vite app, and B12.3 must add enterprise screen assertions."
AUTH_PROFILE_RESULT="Passed API smoke" \
INBOX_RESULT="Passed API smoke" \
CONVERSATION_RESULT="Passed API smoke" \
CRM_RESULT="Passed contact/company API smoke" \
SEARCH_RESULT="Passed search API smoke" \
WIDGET_RESULT="Passed API smoke" \
CSAT_RESULT="Passed public show API smoke" \
ENTERPRISE_RESULT="Pending B12.3 browser/API smoke" \
write_report "API smoke passed for GoChat frontend core paths; live browser smoke not run in this mode." "Command run: \`scripts/parity_frontend_smoke.sh --api-smoke\`. Logs and payload captures are under \`$LOG_DIR\`. Browser navigation assertions cover the GoChat Vite app; enterprise screen assertions run in enterprise modes."
echo "api smoke passed; report written to $REPORT_PATH"
}
run_browser_smoke() {
run_api_smoke
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/app/javascript/entrypoints/dashboard.js" "Chatwoot Vite"
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/app/javascript/entrypoints/dashboard.js" "GoChat Vite"
GOCHAT_ROOT="$ROOT" \
CHATWOOT_DIR="$CHATWOOT_DIR" \
GOCHAT_SMOKE_LOG_DIR="$LOG_DIR" \
GOCHAT_SMOKE_API_HOST="$API_HOST" \
GOCHAT_SMOKE_API_PORT="$API_PORT" \
GOCHAT_SMOKE_FRONTEND_HOST="$FRONTEND_HOST" \
GOCHAT_SMOKE_FRONTEND_PORT="$FRONTEND_PORT" \
node --experimental-websocket "$ROOT/scripts/parity_frontend_browser_smoke.mjs"
CHATWOOT_DIR="$CHATWOOT_DIR" \
GOCHAT_SMOKE_LOG_DIR="$LOG_DIR" \
GOCHAT_SMOKE_API_HOST="$API_HOST" \
GOCHAT_SMOKE_API_PORT="$API_PORT" \
GOCHAT_SMOKE_FRONTEND_HOST="$FRONTEND_HOST" \
GOCHAT_SMOKE_FRONTEND_PORT="$FRONTEND_PORT" \
node --experimental-websocket "$ROOT/scripts/parity_frontend_browser_smoke.mjs"
BOOT_BACKEND_RESULT="Passed /health during browser smoke" \
BOOT_FRONTEND_RESULT="Passed Vite readiness during browser smoke" \
AUTH_PROFILE_RESULT="Passed browser login plus API smoke" \
INBOX_RESULT="Passed API smoke" \
CONVERSATION_RESULT="Passed dashboard browser request plus API smoke" \
CRM_RESULT="Passed contact/company API smoke" \
SEARCH_RESULT="Passed search API smoke" \
WIDGET_RESULT="Passed API smoke" \
CSAT_RESULT="Passed public show API smoke" \
ENTERPRISE_RESULT="Pending B12.3 browser/API smoke" \
write_report "Browser smoke passed for reused Chatwoot login, dashboard boot, and widget boot; API smoke passed for core frontend paths." "Command run: \`scripts/parity_frontend_smoke.sh --browser-smoke\`. Browser report: \`$LOG_DIR/browser-smoke-report.json\`. B12.3 must add enterprise screen assertions."
BOOT_FRONTEND_RESULT="Passed Vite readiness during browser smoke" \
AUTH_PROFILE_RESULT="Passed browser login plus API smoke" \
INBOX_RESULT="Passed API smoke" \
CONVERSATION_RESULT="Passed dashboard browser request plus API smoke" \
CRM_RESULT="Passed contact/company API smoke" \
SEARCH_RESULT="Passed search API smoke" \
WIDGET_RESULT="Passed API smoke" \
CSAT_RESULT="Passed public show API smoke" \
ENTERPRISE_RESULT="Pending B12.3 browser/API smoke" \
write_report "Browser smoke passed for GoChat login, dashboard, Billing matrix, and widget boot; API smoke passed for core frontend paths." "Command run: \`scripts/parity_frontend_smoke.sh --browser-smoke\`. Browser report: \`$LOG_DIR/browser-smoke-report.json\`."
echo "browser smoke passed; report written to $REPORT_PATH"
}
@@ -713,15 +733,15 @@ run_enterprise_api_smoke() {
json_assert "$body" 'data.payload && data.payload.length >= 1 && data.payload[0].copilot_thread' "Copilot thread messages return nested payload"
BOOT_BACKEND_RESULT="Passed /health during enterprise smoke" \
AUTH_PROFILE_RESULT="Passed API smoke" \
INBOX_RESULT="Passed API smoke" \
CONVERSATION_RESULT="Passed API smoke" \
CRM_RESULT="Passed contact/company API smoke" \
SEARCH_RESULT="Passed search API smoke" \
WIDGET_RESULT="Passed widget browser boot plus API smoke" \
CSAT_RESULT="Passed public/account/download enterprise smoke" \
ENTERPRISE_RESULT="Passed enterprise API smoke for SLA, CSAT, automation, macros, audit, custom roles, capacity, Captain, and Copilot" \
write_report "Enterprise API smoke passed for reused Chatwoot enterprise paths; live enterprise browser navigation remains optional." "Command run: \`scripts/parity_frontend_smoke.sh --enterprise-smoke\`. Logs and payload captures are under \`$LOG_DIR\`. Next B12.3 browser work should load the enterprise screens through the reused Vite app."
AUTH_PROFILE_RESULT="Passed API smoke" \
INBOX_RESULT="Passed API smoke" \
CONVERSATION_RESULT="Passed API smoke" \
CRM_RESULT="Passed contact/company API smoke" \
SEARCH_RESULT="Passed search API smoke" \
WIDGET_RESULT="Passed widget browser boot plus API smoke" \
CSAT_RESULT="Passed public/account/download enterprise smoke" \
ENTERPRISE_RESULT="Passed enterprise API smoke for SLA, CSAT, automation, macros, audit, custom roles, capacity, Captain, and Copilot" \
write_report "Enterprise API smoke passed for GoChat enterprise paths; live enterprise browser navigation remains optional." "Command run: \`scripts/parity_frontend_smoke.sh --enterprise-smoke\`. Logs and payload captures are under \`$LOG_DIR\`."
echo "enterprise smoke passed; report written to $REPORT_PATH"
}
@@ -729,24 +749,24 @@ run_enterprise_browser_smoke() {
run_enterprise_api_smoke
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/vite-dev/entrypoints/dashboard.js" "Chatwoot Vite"
GOCHAT_ROOT="$ROOT" \
CHATWOOT_DIR="$CHATWOOT_DIR" \
GOCHAT_SMOKE_LOG_DIR="$LOG_DIR" \
GOCHAT_SMOKE_API_HOST="$API_HOST" \
GOCHAT_SMOKE_API_PORT="$API_PORT" \
GOCHAT_SMOKE_FRONTEND_HOST="$FRONTEND_HOST" \
GOCHAT_SMOKE_FRONTEND_PORT="$FRONTEND_PORT" \
node "$ROOT/scripts/parity_frontend_browser_smoke.mjs" --enterprise
CHATWOOT_DIR="$CHATWOOT_DIR" \
GOCHAT_SMOKE_LOG_DIR="$LOG_DIR" \
GOCHAT_SMOKE_API_HOST="$API_HOST" \
GOCHAT_SMOKE_API_PORT="$API_PORT" \
GOCHAT_SMOKE_FRONTEND_HOST="$FRONTEND_HOST" \
GOCHAT_SMOKE_FRONTEND_PORT="$FRONTEND_PORT" \
node "$ROOT/scripts/parity_frontend_browser_smoke.mjs" --enterprise
BOOT_BACKEND_RESULT="Passed /health during enterprise browser smoke" \
BOOT_FRONTEND_RESULT="Passed Vite readiness during enterprise browser smoke" \
AUTH_PROFILE_RESULT="Passed browser login plus API smoke" \
INBOX_RESULT="Passed API smoke" \
CONVERSATION_RESULT="Passed dashboard browser request plus API smoke" \
CRM_RESULT="Passed contact/company API smoke" \
SEARCH_RESULT="Passed search API smoke" \
WIDGET_RESULT="Passed API smoke" \
CSAT_RESULT="Passed CSAT API/download and browser route requests" \
ENTERPRISE_RESULT="Passed enterprise browser route requests for SLA, CSAT, automation, macros, audit, custom roles, capacity, Captain, and Copilot" \
write_report "Enterprise browser smoke passed for reused Chatwoot enterprise route requests and widget boot; enterprise API smoke passed." "Command run: \`scripts/parity_frontend_smoke.sh --enterprise-browser-smoke\`. Browser report: \`$LOG_DIR/browser-smoke-report.json\`."
BOOT_FRONTEND_RESULT="Passed Vite readiness during enterprise browser smoke" \
AUTH_PROFILE_RESULT="Passed browser login plus API smoke" \
INBOX_RESULT="Passed API smoke" \
CONVERSATION_RESULT="Passed dashboard browser request plus API smoke" \
CRM_RESULT="Passed contact/company API smoke" \
SEARCH_RESULT="Passed search API smoke" \
WIDGET_RESULT="Passed API smoke" \
CSAT_RESULT="Passed CSAT API/download and browser route requests" \
ENTERPRISE_RESULT="Passed enterprise browser route requests for SLA, CSAT, automation, macros, audit, custom roles, capacity, Captain, and Copilot" \
write_report "Enterprise browser smoke passed for GoChat enterprise route requests and widget boot; enterprise API smoke passed." "Command run: \`scripts/parity_frontend_smoke.sh --enterprise-browser-smoke\`. Browser report: \`$LOG_DIR/browser-smoke-report.json\`."
echo "enterprise browser smoke passed; report written to $REPORT_PATH"
}
@@ -791,13 +811,13 @@ echo "starting GoChat backend..."
(cd "$ROOT" && "${backend_cmd[@]}") >"$LOG_DIR/gochat.log" 2>&1 &
wait_url "http://$API_HOST:$API_PORT/health" "GoChat"
echo "starting reused Chatwoot frontend..."
echo "starting GoChat frontend..."
(cd "$CHATWOOT_DIR" && "${frontend_cmd[@]}") >"$LOG_DIR/chatwoot-vite.log" 2>&1 &
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/app/javascript/entrypoints/dashboard.js" "Chatwoot Vite"
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/app/javascript/entrypoints/dashboard.js" "GoChat Vite"
BOOT_BACKEND_RESULT="Passed boot readiness" \
BOOT_FRONTEND_RESULT="Passed boot readiness" \
write_report "Boot smoke passed for backend and reused Chatwoot frontend." "This run verified process boot and HTTP readiness only. B12.2 must add browser/API path assertions and update the smoke matrix."
BOOT_FRONTEND_RESULT="Passed boot readiness" \
write_report "Boot smoke passed for backend and GoChat frontend." "This run verified process boot and HTTP readiness only. Browser/API path assertions run in --browser-smoke."
echo "smoke boot passed; report written to $REPORT_PATH"
if [[ "$KEEP_ALIVE" == "true" ]]; then