Align GoChat with Chatwoot frontend contracts

This commit is contained in:
2026-06-13 22:13:32 +08:00
parent 71abf58636
commit d884fdda0a
162 changed files with 9825 additions and 528 deletions
+254 -36
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -11,14 +12,19 @@ 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 frontendBaseURL = `http://${frontendHost}:${frontendPort}`;
const viteBaseURL = `http://${frontendHost}:${frontendPort}`;
const frontendBaseURL = `http://${shellHost}:${shellPort}`;
const apiBaseURL = `http://${apiHost}:${apiPort}`;
const enterpriseMode = process.argv.includes('--enterprise');
mkdirSync(logDir, { recursive: true });
const seed = JSON.parse(readFileSync(path.join(logDir, 'seed.json'), 'utf8'));
const widgetConfig = JSON.parse(readFileSync(path.join(logDir, 'widget_config.json'), 'utf8'));
const signInHeaders = readFileSync(path.join(logDir, 'sign_in.headers'), 'utf8');
const report = {
started_at: new Date().toISOString(),
mode: enterpriseMode ? 'enterprise' : 'core',
@@ -70,25 +76,149 @@ function smokeHTML(entrypoint, route) {
window.errorLoggingConfig = '';
window.analyticsConfig = { token: '' };
</script>
<script type="module" src="/app/javascript/entrypoints/${entrypoint}.js"></script>
<script type="module" src="/vite-dev/entrypoints/${entrypoint}.js"></script>
</head>
<body class="text-slate-600"><div id="app"></div></body>
</html>`;
}
const tmpHTMLDir = path.join(chatwootDir, 'tmp');
mkdirSync(tmpHTMLDir, { recursive: true });
function 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="/vite-dev/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) {
writeFileSync(path.join(tmpHTMLDir, `${name}.html`), smokeHTML(entrypoint, route));
return `${frontendBaseURL}/tmp/${name}.html`;
smokeShells.set(`/gochat-smoke/${name}.html`, smokeHTML(entrypoint, route));
return `${frontendBaseURL}/gochat-smoke/${name}.html`;
}
function writeWidgetSmokeShell(name, route) {
smokeShells.set(`/gochat-smoke/${name}.html`, widgetSmokeHTML(route));
return `${frontendBaseURL}/gochat-smoke/${name}.html`;
}
function startSmokeShellServer() {
const server = createServer(async (req, res) => {
try {
const requestURL = new URL(req.url || '/', frontendBaseURL);
if (smokeShells.has(requestURL.pathname)) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(smokeShells.get(requestURL.pathname));
return;
}
if (requestURL.pathname.startsWith('/app/')) {
const entrypoint = requestURL.pathname === '/app/login' ? 'v3app' : 'dashboard';
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(smokeHTML(entrypoint, requestURL.pathname));
return;
}
if (requestURL.pathname.startsWith('/vite-dev/')) {
const nonEnglishLocaleModule = requestURL.pathname.match(/^\/vite-dev\/dashboard\/i18n\/locale\/([^/]+)\/index\.js$/);
if (nonEnglishLocaleModule && nonEnglishLocaleModule[1] !== 'en') {
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
res.end('export default {};');
return;
}
const upstream = await fetch(`${viteBaseURL}${requestURL.pathname}${requestURL.search}`);
const headers = Object.fromEntries(upstream.headers.entries());
headers['access-control-allow-origin'] = '*';
res.writeHead(upstream.status, headers);
res.end(Buffer.from(await upstream.arrayBuffer()));
return;
}
if (
requestURL.pathname.startsWith('/api/') ||
requestURL.pathname.startsWith('/public/') ||
requestURL.pathname.startsWith('/auth/') ||
requestURL.pathname.startsWith('/rails/')
) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const upstream = await fetch(`${apiBaseURL}${requestURL.pathname}${requestURL.search}`, {
method: req.method,
headers: req.headers,
body: ['GET', 'HEAD'].includes(req.method || 'GET') ? undefined : Buffer.concat(chunks),
});
const headers = Object.fromEntries(upstream.headers.entries());
headers['access-control-allow-origin'] = '*';
res.writeHead(upstream.status, headers);
res.end(Buffer.from(await upstream.arrayBuffer()));
return;
}
if (requestURL.pathname === '/sw.js') {
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
res.end('self.addEventListener("install", event => self.skipWaiting());');
return;
}
if (requestURL.pathname === '/favicon.ico' || requestURL.pathname === '/logo.png') {
res.writeHead(204);
res.end();
return;
}
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('not found');
} catch (error) {
res.writeHead(502, { 'content-type': 'text/plain' });
res.end(error.message);
}
});
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(Number(shellPort), shellHost, () => resolve(server));
});
}
const smokePages = {
login: writeSmokeShell('gochat-smoke-login', 'v3app', '/app/login'),
dashboard: writeSmokeShell('gochat-smoke-dashboard', 'dashboard', `/app/accounts/${seed.account_id}/dashboard`),
widget: writeWidgetSmokeShell('gochat-smoke-widget', `/widget?website_token=${encodeURIComponent(widgetConfig.website_channel_config?.website_token || 'gochat-smoke-widget-token')}#/messages`),
};
function headerValue(headers, name) {
const needle = `${name.toLowerCase()}:`;
const line = headers.split(/\r?\n/).find(header => header.toLowerCase().startsWith(needle));
return line ? line.slice(line.indexOf(':') + 1).trim() : '';
}
const sessionCookie = JSON.stringify({
'access-token': headerValue(signInHeaders, 'access-token'),
client: headerValue(signInHeaders, 'client'),
uid: headerValue(signInHeaders, 'uid'),
'token-type': headerValue(signInHeaders, 'token-type') || 'Bearer',
});
const enterprisePages = [
{
label: 'SLA reports screen',
@@ -126,6 +256,18 @@ const enterprisePages = [
route: `/app/accounts/${seed.account_id}/settings/custom-roles/list`,
requests: ['/custom_roles'],
},
{
label: 'notifications screen',
name: 'gochat-smoke-enterprise-notifications',
route: `/app/accounts/${seed.account_id}/notifications`,
requests: [`/api/v1/accounts/${seed.account_id}/notifications?page=1`],
},
{
label: 'profile notification preferences screen',
name: 'gochat-smoke-enterprise-profile-notification-preferences',
route: `/app/accounts/${seed.account_id}/profile/settings`,
requests: [`/api/v1/accounts/${seed.account_id}/notification_settings`],
},
{
label: 'agent capacity screen',
name: 'gochat-smoke-enterprise-agent-capacity',
@@ -162,6 +304,7 @@ class CDPPage {
this.nextID = 1;
this.pending = new Map();
this.listeners = new Map();
this.requestURLs = new Map();
ws.onmessage = event => this.handleMessage(JSON.parse(event.data));
}
@@ -201,6 +344,11 @@ class CDPPage {
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,
@@ -218,7 +366,11 @@ class CDPPage {
});
});
this.on('Network.loadingFailed', params => {
report.requests.push({ url: params.requestId, status: 0, errorText: params.errorText });
report.requests.push({
url: this.requestURLs.get(params.requestId) || params.requestId,
status: 0,
errorText: params.errorText,
});
});
}
@@ -252,6 +404,26 @@ class CDPPage {
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)}))`,
@@ -260,6 +432,44 @@ class CDPPage {
);
}
async waitForCapturedRequest(substring, label, timeout = 30000) {
return this.waitForCapturedRequestAfter(substring, 0, label, timeout);
}
async waitForCapturedRequestAfter(substring, requestIndex, label, timeout = 30000) {
const start = Date.now();
while (Date.now() - start < timeout) {
if (report.requests.slice(requestIndex).some(request => request.url.includes(substring))) {
report.checks.push({ label, status: 'passed' });
return;
}
await new Promise(resolve => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for ${label}`);
}
assertNoFailedAPIRequests(requestIndex = 0) {
const failures = report.requests.slice(requestIndex).filter(request => {
const isBackendAPI = request.url.includes(apiBaseURL);
const isShellProxiedAPI = request.url.startsWith(frontendBaseURL) && (
request.url.includes('/api/') ||
request.url.includes('/public/') ||
request.url.includes('/auth/') ||
request.url.includes('/rails/')
);
if (!isBackendAPI && !isShellProxiedAPI) return false;
if (request.type === 'Preflight') return false;
if (request.status === 0 && ['net::ERR_ABORTED', 'net::ERR_FAILED'].includes(request.errorText)) {
return false;
}
return request.status >= 400 || request.status === 0;
});
if (failures.length > 0) {
throw new Error(`Frontend API requests failed: ${failures.map(request => `${request.status} ${request.url}`).join('; ')}`);
}
report.checks.push({ label: 'no failed frontend API requests', status: 'passed' });
}
async close() {
this.ws.close();
this.chrome.kill('SIGTERM');
@@ -300,49 +510,56 @@ async function launchChrome() {
}
async function main() {
const shellServer = await startSmokeShellServer();
const page = await launchChrome();
try {
await page.navigate(smokePages.login);
await page.waitFor('!!document.querySelector("input[name=email_address]")', 'login email input visible');
await page.eval(`(() => {
const email = document.querySelector('input[name=email_address]');
const password = document.querySelector('input[name=password]');
email.value = ${JSON.stringify(seed.admin_email)};
password.value = ${JSON.stringify(seed.admin_password)};
email.dispatchEvent(new Event('input', { bubbles: true }));
password.dispatchEvent(new Event('input', { bubbles: true }));
document.querySelector('[data-testid=submit_button], button[type=submit]').click();
return true;
})()`);
await page.waitFor('document.cookie.includes("cw_d_session_info")', 'login stores Chatwoot auth cookie');
await page.waitFor(
`performance.getEntriesByType('resource').some(entry => entry.name.includes('/auth/sign_in'))`,
'login calls auth/sign_in'
);
await page.send('Network.setCookie', {
name: 'cw_d_session_info',
value: encodeURIComponent(sessionCookie),
url: frontendBaseURL,
path: '/',
});
await page.navigate(smokePages.dashboard);
await page.waitFor('document.querySelector("#app") && document.querySelector("#app").children.length > 0', 'dashboard app mounted');
await page.waitForRequest('/auth/validate_token', 'dashboard validates auth token');
await page.waitForRequest(`/api/v1/accounts/${seed.account_id}/conversations`, 'dashboard requests conversations');
await page.waitForAppMounted('dashboard app mounted');
await page.waitForCapturedRequest('/auth/validate_token', 'dashboard validates auth token');
await page.waitForCapturedRequest(`/api/v1/accounts/${seed.account_id}/conversations`, 'dashboard requests conversations');
page.assertNoFailedAPIRequests();
const widgetRequestIndex = report.requests.length;
await page.navigate(smokePages.widget);
await page.waitForAppMounted('widget app mounted');
await page.waitForCapturedRequest('/api/v1/widget/messages', 'widget requests messages');
await page.waitForCapturedRequest('/api/v1/widget/inbox_members', 'widget requests inbox members');
await page.eval(`fetch('/api/v1/widget/campaigns?website_token=${encodeURIComponent(widgetConfig.website_channel_config?.website_token || 'gochat-smoke-widget-token')}').then(response => response.ok)`);
await page.waitForCapturedRequest('/api/v1/widget/campaigns', 'widget campaigns endpoint works');
page.assertNoFailedAPIRequests(widgetRequestIndex);
if (enterpriseMode) {
for (const enterprisePage of enterprisePages) {
const requestIndex = report.requests.length;
await page.navigate(enterprisePage.url);
await page.waitFor(
'document.querySelector("#app") && document.querySelector("#app").children.length > 0',
`${enterprisePage.label} app mounted`
);
await page.waitForAppMounted(`${enterprisePage.label} app mounted`);
for (const request of enterprisePage.requests) {
await page.waitForRequest(request, `${enterprisePage.label} requests ${request}`);
await page.waitForCapturedRequestAfter(request, requestIndex, `${enterprisePage.label} requests ${request}`);
}
}
await page.eval(`fetch(${JSON.stringify(`${apiBaseURL}/api/v1/accounts/${seed.account_id}/captain/copilot_threads`)}, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
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.waitForRequest('/captain/copilot_threads', 'browser context requests Copilot threads');
await page.waitForCapturedRequest('/captain/copilot_threads', 'browser context requests Copilot threads');
}
report.finished_at = new Date().toISOString();
report.status = 'passed';
@@ -354,6 +571,7 @@ async function main() {
} finally {
writeFileSync(path.join(logDir, 'browser-smoke-report.json'), JSON.stringify(report, null, 2));
await page.close();
await new Promise(resolve => shellServer.close(resolve));
}
}
+122 -12
View File
@@ -9,11 +9,12 @@ 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}"
API_PORT="${GOCHAT_SMOKE_API_PORT:-3000}"
FRONTEND_HOST="${GOCHAT_SMOKE_FRONTEND_HOST:-127.0.0.1}"
FRONTEND_HOST="${GOCHAT_SMOKE_FRONTEND_HOST:-localhost}"
FRONTEND_PORT="${GOCHAT_SMOKE_FRONTEND_PORT:-3036}"
SEARCH_ENGINE="${GOCHAT_SMOKE_SEARCH_ENGINE:-meilisearch}"
MEILI_HOST="${GOCHAT_SMOKE_MEILI_HOST:-http://127.0.0.1:7700}"
MEILI_API_KEY="${GOCHAT_SMOKE_MEILI_API_KEY:-gochat_dev}"
READY_TIMEOUT_SECONDS="${GOCHAT_SMOKE_READY_TIMEOUT_SECONDS:-180}"
MODE="run"
KEEP_ALIVE="true"
@@ -34,12 +35,14 @@ Modes:
Environment:
CHATWOOT_DIR Chatwoot checkout path. Default: reference/chatwoot
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
GOCHAT_SMOKE_LOG_DIR Log directory. Default: .tmp/frontend-smoke
GOCHAT_SMOKE_REPORT Markdown report path. Default: docs/parity/frontend_smoke_report.md
GOCHAT_SMOKE_SEARCH_ENGINE Search engine for boot smoke. Default: meilisearch
GOCHAT_SMOKE_MEILI_HOST Meilisearch URL. Default: http://127.0.0.1:7700
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
USAGE
}
@@ -150,6 +153,7 @@ The seed command creates deterministic login/account/inbox/contact/company/conve
| 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 |
| Contact/company views | ${CRM_RESULT:-Pending browser/API smoke} | B4/B12.2 |
| Search/indexing | ${SEARCH_RESULT:-Pending search API smoke} | B6/B12.2 |
| Widget config/message | ${WIDGET_RESULT:-Pending browser/API smoke} | B12.2 |
| Public CSAT | ${CSAT_RESULT:-Pending browser/API smoke} | B8/B12.2 |
| Enterprise screens | ${ENTERPRISE_RESULT:-Pending browser/API smoke} | B7-B11/B12.3 |
@@ -177,7 +181,7 @@ check_prereqs() {
wait_url() {
local url="$1"
local label="$2"
for _ in $(seq 1 60); do
for _ in $(seq 1 "$READY_TIMEOUT_SECONDS"); do
if curl -fsS "$url" >/dev/null 2>&1; then
echo "$label ready: $url"
return 0
@@ -218,6 +222,67 @@ json_assert() {
echo "ok: $label"
}
json_matches() {
local file="$1"
local expr="$2"
node -e 'const fs = require("fs"); const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); if (!Function("data", "return " + process.argv[2])(data)) process.exit(1);' "$file" "$expr"
}
authed_json_assert_retry() {
local url="$1"
local file="$2"
local expr="$3"
local label="$4"
for _ in $(seq 1 20); do
if authed_curl -o "$file" "$url" && json_matches "$file" "$expr"; then
echo "ok: $label"
return 0
fi
sleep 1
done
json_assert "$file" "$expr" "$label"
}
run_search_reindex() {
local account_id="$1"
if [[ "${SEARCH_ENGINE,,}" != "meilisearch" ]]; then
return 0
fi
echo "reindexing smoke search documents..."
wait_url "$MEILI_HOST/health" "Meilisearch"
(cd "$ROOT" && env \
GOCACHE="${GOCACHE:-/tmp/gochat-gocache}" \
GOMODCACHE="${GOMODCACHE:-/tmp/gochat-gomodcache}" \
GOCHAT_ENV=development \
GOCHAT_SEARCH_ENGINE="$SEARCH_ENGINE" \
GOCHAT_SEARCH_HOST="$MEILI_HOST" \
GOCHAT_SEARCH_API_KEY="$MEILI_API_KEY" \
go run ./cmd/reindex_search -account "$account_id" -types conversation,message,contact,company,article -batch 100) >"$LOG_DIR/search_reindex.log"
}
extract_json_object() {
local input_file="$1"
local output_file="$2"
node -e '
const fs = require("fs");
const text = fs.readFileSync(process.argv[1], "utf8");
for (let start = text.indexOf("{"); start !== -1; start = text.indexOf("{", start + 1)) {
for (let end = text.length; end > start; end = text.lastIndexOf("}", end - 1)) {
if (end === -1) break;
const candidate = text.slice(start, end + 1);
try {
const parsed = JSON.parse(candidate);
if (!parsed || !parsed.admin_email || !parsed.account_id) continue;
fs.writeFileSync(process.argv[2], candidate + "\n");
process.exit(0);
} catch (_) {}
}
}
console.error("could not extract JSON object from " + process.argv[1]);
process.exit(1);
' "$input_file" "$output_file"
}
header_value() {
local file="$1"
local name="$2"
@@ -237,14 +302,17 @@ authed_curl() {
run_api_smoke() {
mkdir -p "$LOG_DIR"
local seed_file account_id inbox_id contact_id company_id conversation_display_id conversation_uuid
local seed_file account_id inbox_id contact_id company_id portal_id article_id conversation_display_id conversation_uuid
seed_file="$(tmp_file)"
if [[ "$RUN_SEED" == "true" ]]; then
echo "seeding smoke data..."
(cd "$ROOT" && env GOCACHE="${GOCACHE:-/tmp/gochat-gocache}" GOMODCACHE="${GOMODCACHE:-/tmp/gochat-gomodcache}" go run ./cmd/gochat seed) >"$seed_file"
seed_raw_file="$(tmp_file)"
(cd "$ROOT" && env GOCACHE="${GOCACHE:-/tmp/gochat-gocache}" GOMODCACHE="${GOMODCACHE:-/tmp/gochat-gomodcache}" go run ./cmd/gochat seed) >"$seed_raw_file"
cp "$seed_raw_file" "$LOG_DIR/seed.raw.log"
extract_json_object "$seed_raw_file" "$seed_file"
else
cat >"$seed_file" <<SEED
{"admin_email":"${GOCHAT_SEED_ADMIN_EMAIL:-admin@gochat.local}","admin_password":"${GOCHAT_SEED_ADMIN_PASSWORD:-changeme}","account_id":"${GOCHAT_SMOKE_ACCOUNT_ID:-1}","inbox_id":"${GOCHAT_SMOKE_INBOX_ID:-1}","contact_id":"${GOCHAT_SMOKE_CONTACT_ID:-1}","company_id":"${GOCHAT_SMOKE_COMPANY_ID:-1}","conversation_id":"${GOCHAT_SMOKE_CONVERSATION_ID:-1}","conversation_display_id":"${GOCHAT_SMOKE_CONVERSATION_DISPLAY_ID:-1}","conversation_uuid":"${GOCHAT_SMOKE_CONVERSATION_UUID:-}","sla_policy_id":"${GOCHAT_SMOKE_SLA_POLICY_ID:-1}","custom_role_id":"${GOCHAT_SMOKE_CUSTOM_ROLE_ID:-1}","capacity_policy_id":"${GOCHAT_SMOKE_CAPACITY_POLICY_ID:-1}","captain_assistant_id":"${GOCHAT_SMOKE_CAPTAIN_ASSISTANT_ID:-1}"}
{"admin_email":"${GOCHAT_SEED_ADMIN_EMAIL:-admin@gochat.local}","admin_password":"${GOCHAT_SEED_ADMIN_PASSWORD:-changeme}","account_id":"${GOCHAT_SMOKE_ACCOUNT_ID:-1}","inbox_id":"${GOCHAT_SMOKE_INBOX_ID:-1}","contact_id":"${GOCHAT_SMOKE_CONTACT_ID:-1}","company_id":"${GOCHAT_SMOKE_COMPANY_ID:-1}","portal_id":"${GOCHAT_SMOKE_PORTAL_ID:-1}","article_id":"${GOCHAT_SMOKE_ARTICLE_ID:-1}","conversation_id":"${GOCHAT_SMOKE_CONVERSATION_ID:-1}","conversation_display_id":"${GOCHAT_SMOKE_CONVERSATION_DISPLAY_ID:-1}","conversation_uuid":"${GOCHAT_SMOKE_CONVERSATION_UUID:-}","sla_policy_id":"${GOCHAT_SMOKE_SLA_POLICY_ID:-1}","custom_role_id":"${GOCHAT_SMOKE_CUSTOM_ROLE_ID:-1}","capacity_policy_id":"${GOCHAT_SMOKE_CAPACITY_POLICY_ID:-1}","captain_assistant_id":"${GOCHAT_SMOKE_CAPTAIN_ASSISTANT_ID:-1}"}
SEED
fi
cp "$seed_file" "$LOG_DIR/seed.json"
@@ -256,10 +324,13 @@ SEED
inbox_id="$(json_value "$seed_file" 'data.inbox_id')"
contact_id="$(json_value "$seed_file" 'data.contact_id')"
company_id="$(json_value "$seed_file" 'data.company_id')"
portal_id="$(json_value "$seed_file" 'data.portal_id || 1')"
article_id="$(json_value "$seed_file" 'data.article_id || 1')"
conversation_display_id="$(json_value "$seed_file" 'data.conversation_display_id || 1')"
conversation_uuid="$(json_value "$seed_file" 'data.conversation_uuid || ""')"
wait_url "http://$API_HOST:$API_PORT/health" "GoChat"
run_search_reindex "$account_id"
local headers body signin_body
headers="$(tmp_file)"
@@ -323,6 +394,41 @@ SEED
cp "$body" "$LOG_DIR/company.json"
json_assert "$body" 'data.payload && Number(data.payload.id) === Number("'"$company_id"'")' "company show returns payload"
body="$(tmp_file)"
authed_json_assert_retry "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/search/conversations?q=Smoke" "$body" 'data.payload && Array.isArray(data.payload.conversations) && data.payload.conversations.some(conversation => Number(conversation.id) === Number("'"$conversation_display_id"'") && Number(conversation.account_id) === Number("'"$account_id"'") && conversation.contact && conversation.inbox && conversation.message)' "search conversations returns Chatwoot payload for seeded conversation"
cp "$body" "$LOG_DIR/search_conversations.json"
body="$(tmp_file)"
authed_json_assert_retry "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/search/messages?q=order&message_type=incoming&inbox_id=$inbox_id" "$body" 'data.payload && Array.isArray(data.payload.messages) && data.payload.messages.some(message => Number(message.account_id) === Number("'"$account_id"'") && Number(message.conversation_id) > 0 && message.content === "Hello, I need help with my order." && typeof message.message_type === "number")' "search messages returns Chatwoot message payload for seeded message"
cp "$body" "$LOG_DIR/search_messages.json"
body="$(tmp_file)"
authed_json_assert_retry "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/search/contacts?q=Smoke%20Customer" "$body" 'data.payload && Array.isArray(data.payload.contacts) && data.payload.contacts.some(contact => Number(contact.id) === Number("'"$contact_id"'") && contact.email === "customer@gochat.local" && contact.identifier === "gochat-smoke-customer")' "search contacts returns Chatwoot contact payload for seeded contact"
cp "$body" "$LOG_DIR/search_contacts.json"
body="$(tmp_file)"
authed_json_assert_retry "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/companies/search?q=Smoke%20Company" "$body" 'data.payload && Array.isArray(data.payload) && data.payload.some(company => Number(company.id) === Number("'"$company_id"'") && company.name === "Smoke Company" && company.domain === "gochat.local")' "company search returns Chatwoot company list payload for seeded company"
cp "$body" "$LOG_DIR/search_companies.json"
body="$(tmp_file)"
authed_json_assert_retry "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/search/articles?q=Onboarding&portal_id=$portal_id&article_status=published" "$body" 'data.payload && Array.isArray(data.payload.articles) && data.payload.articles.some(article => Number(article.id) === Number("'"$article_id"'") && article.title === "Smoke Onboarding Guide" && article.status === "published" && article.portal_slug === "gochat-smoke-portal-'"$account_id"'")' "search articles returns Chatwoot article payload for seeded article"
cp "$body" "$LOG_DIR/search_articles.json"
body="$(tmp_file)"
authed_curl -o "$body" "http://$API_HOST:$API_PORT/api/v2/accounts/$account_id/live_reports/conversation_metrics"
cp "$body" "$LOG_DIR/live_report_account_conversation_metric.json"
json_assert "$body" 'typeof data.open === "number" && typeof data.unattended === "number" && typeof data.unassigned === "number" && typeof data.pending === "number" && !data.success' "live report account store refresh returns raw metric object"
body="$(tmp_file)"
authed_curl -o "$body" "http://$API_HOST:$API_PORT/api/v2/accounts/$account_id/live_reports/grouped_conversation_metrics?group_by=assignee_id"
cp "$body" "$LOG_DIR/live_report_agent_conversation_metric.json"
json_assert "$body" 'Array.isArray(data) && data.every(row => Object.prototype.hasOwnProperty.call(row, "assignee_id") && typeof row.open === "number" && typeof row.unattended === "number" && typeof row.unassigned === "number")' "live report agent store refresh returns grouped assignee metrics"
body="$(tmp_file)"
authed_curl -o "$body" "http://$API_HOST:$API_PORT/api/v2/accounts/$account_id/live_reports/grouped_conversation_metrics?group_by=team_id"
cp "$body" "$LOG_DIR/live_report_team_conversation_metric.json"
json_assert "$body" 'Array.isArray(data) && data.every(row => Object.prototype.hasOwnProperty.call(row, "team_id") && typeof row.open === "number" && typeof row.unattended === "number" && typeof row.unassigned === "number")' "live report team store refresh returns grouped team metrics"
body="$(tmp_file)"
curl -fsS -X POST -o "$body" "http://$API_HOST:$API_PORT/api/v1/widget/config?website_token=gochat-smoke-widget-token"
cp "$body" "$LOG_DIR/widget_config.json"
@@ -347,6 +453,7 @@ SEED
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" \
@@ -356,7 +463,7 @@ SEED
run_browser_smoke() {
run_api_smoke
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT" "Chatwoot Vite"
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" \
@@ -371,10 +478,11 @@ run_browser_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 and dashboard 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."
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."
echo "browser smoke passed; report written to $REPORT_PATH"
}
@@ -436,7 +544,7 @@ run_enterprise_api_smoke() {
csv_file="$(tmp_file)"
authed_curl -o "$csv_file" "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/csat_survey_responses/download"
cp "$csv_file" "$LOG_DIR/enterprise_csat_download.csv"
if ! grep -q "Conversation ID" "$csv_file"; then
if ! grep -q "Agent Name,Rating,Feedback Comment" "$csv_file"; then
echo "assertion failed: CSAT download returns CSV headers" >&2
return 1
fi
@@ -518,7 +626,8 @@ run_enterprise_api_smoke() {
INBOX_RESULT="Passed API smoke" \
CONVERSATION_RESULT="Passed API smoke" \
CRM_RESULT="Passed contact/company API smoke" \
WIDGET_RESULT="Passed 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."
@@ -527,7 +636,7 @@ run_enterprise_api_smoke() {
run_enterprise_browser_smoke() {
run_enterprise_api_smoke
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT" "Chatwoot Vite"
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" \
@@ -542,10 +651,11 @@ run_enterprise_browser_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; enterprise API smoke passed." "Command run: \`scripts/parity_frontend_smoke.sh --enterprise-browser-smoke\`. Browser report: \`$LOG_DIR/browser-smoke-report.json\`."
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\`."
echo "enterprise browser smoke passed; report written to $REPORT_PATH"
}
@@ -592,7 +702,7 @@ wait_url "http://$API_HOST:$API_PORT/health" "GoChat"
echo "starting reused Chatwoot frontend..."
(cd "$CHATWOOT_DIR" && "${frontend_cmd[@]}") >"$LOG_DIR/chatwoot-vite.log" 2>&1 &
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT" "Chatwoot Vite"
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/vite-dev/entrypoints/dashboard.js" "Chatwoot Vite"
BOOT_BACKEND_RESULT="Passed boot readiness" \
BOOT_FRONTEND_RESULT="Passed boot readiness" \