Files
2026-07-07 07:27:13 +08:00

2145 lines
108 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
const appOrigin = process.env.SMOKE_APP_ORIGIN || `http://127.0.0.1:${await getFreePort()}`;
const storageKey = "issue-hub-ai:dispatch-platform:v1";
const syncSettingsKey = "issue-hub-ai:sync-settings:v1";
const syncTokenKey = "issue-hub-ai:sync-token:v1";
const aiSettingsKey = "issue-hub-ai:ai-capability-settings:v1";
const aiApiKeySecretKey = "issue-hub-ai:secret:openai-compatible-api-key";
const notificationSettingsKey = "issue-hub-ai:notification-policy-settings:v1";
const permissionSettingsKey = "issue-hub-ai:permission-policy-settings:v1";
const analyticsLayoutKey = "issue-hub-ai:analytics-layout";
const analyticsExportKey = "issue-hub-ai:last-analytics-export";
const analyticsDrilldownKey = "issue-hub-ai:analytics-drilldowns:v1";
const analyticsViewStateKey = "issue-hub-ai:analytics-view-state:v1";
const issuePoolExportKey = "issue-hub-ai:last-issue-pool-export";
const issueProcessExportKey = "issue-hub-ai:last-issue-process-export";
const departmentQueueSettingsKey = "issue-hub-ai:department-queue-settings:v1";
const knowledgeReviewSettingsKey = "issue-hub-ai:knowledge-review-settings:v1";
const workbenchActionKey = "issue-hub-ai:workbench-actions:v1";
const handlerActionKey = "issue-hub-ai:handler-actions:v1";
const attachmentDownloadKey = "issue-hub-ai:attachment-downloads:v1";
const submitPrecheckKey = "issue-hub-ai:submit-precheck:v1";
let chromePort = Number(process.env.SMOKE_CHROME_PORT || 0);
const artifactDir = path.resolve("docs/design/interaction-smoke");
const pageArtifactDir = path.resolve("docs/design/implementation-screenshots");
const capturePagesOnly = process.argv.includes("--capture-pages");
const debugBrowserEvents = process.env.SMOKE_DEBUG_BROWSER === "1";
main().catch((error) => {
console.error(error.stack || error.message);
process.exitCode = 1;
});
async function main() {
if (typeof WebSocket === "undefined") {
throw new Error("This smoke runner needs a Node runtime with global WebSocket support.");
}
if (capturePagesOnly) {
await captureImplementationPages();
return;
}
log("Preparing browser smoke.");
mkdirSync(artifactDir, { recursive: true });
const devServer = await ensureDevServer();
const mockSyncServer = await startMockSyncServer();
const mockAiServer = await startMockAiServer();
const chromeUserDataDir = mkdtempSync(path.join(tmpdir(), "issue-hub-ai-smoke-"));
chromePort = chromePort || await getFreePort();
log("Launching Chrome.");
const chrome = launchChrome(chromeUserDataDir);
try {
log("Connecting to Chrome DevTools.");
const version = await waitForJson(`http://127.0.0.1:${chromePort}/json/version`, 12_000);
const pageTarget = await createPageTarget(`${appOrigin}/#/issue-pool`);
const client = await CdpClient.connect(pageTarget.webSocketDebuggerUrl || version.webSocketDebuggerUrl);
await client.send("Runtime.enable");
await client.send("Page.enable");
if (debugBrowserEvents) {
await client.send("Log.enable");
await client.send("Network.enable");
}
await client.send("Emulation.setDeviceMetricsOverride", {
width: 1440,
height: 940,
deviceScaleFactor: 1,
mobile: false
});
log("Running issue pool smoke.");
await runIssuePoolSmoke(client);
log("Running global sync status smoke.");
await runGlobalSyncStatusSmoke(client, mockSyncServer.origin, mockAiServer.origin);
log("Running live AI triage smoke.");
await runLiveAiTriageSmoke(client);
log("Running client workbench smoke.");
await runClientWorkbenchSmoke(client);
log("Running issue pool bulk smoke.");
await runIssuePoolBulkSmoke(client);
log("Running operational workflow smoke.");
await runOperationalWorkflowSmoke(client);
log("Running escalation smoke.");
await runEscalationSmoke(client);
log("Running mobile submit smoke.");
await runMobileSubmitSmoke(client);
log("Running mobile narrow smoke.");
await runMobileNarrowSmoke(client);
log("Running submit and persistence smoke.");
await runSubmitAndPersistenceSmoke(client);
log("Running knowledge and rule smoke.");
await runKnowledgeAndRuleSmoke(client);
log("Running analytics smoke.");
await runAnalyticsSmoke(client);
console.log("Browser smoke passed.");
} finally {
chrome.kill();
if (devServer) devServer.kill();
await mockSyncServer.close();
await mockAiServer.close();
await cleanupDirectory(chromeUserDataDir);
}
}
async function captureImplementationPages() {
log("Preparing implementation page screenshots.");
mkdirSync(pageArtifactDir, { recursive: true });
const devServer = await ensureDevServer();
const chromeUserDataDir = mkdtempSync(path.join(tmpdir(), "issue-hub-ai-pages-"));
chromePort = chromePort || await getFreePort();
log("Launching Chrome.");
const chrome = launchChrome(chromeUserDataDir);
try {
const version = await waitForJson(`http://127.0.0.1:${chromePort}/json/version`, 12_000);
const pageTarget = await createPageTarget(`${appOrigin}/#/center-dashboard`);
const client = await CdpClient.connect(pageTarget.webSocketDebuggerUrl || version.webSocketDebuggerUrl);
await client.send("Runtime.enable");
await client.send("Page.enable");
if (debugBrowserEvents) {
await client.send("Log.enable");
await client.send("Network.enable");
}
await setViewport(client, 1600, 1000, false);
await navigate(client, `${appOrigin}/#/center-dashboard`);
await evaluate(client, `localStorage.removeItem(${JSON.stringify(storageKey)}); localStorage.removeItem(${JSON.stringify(syncSettingsKey)}); localStorage.removeItem(${JSON.stringify(syncTokenKey)}); localStorage.removeItem(${JSON.stringify(aiSettingsKey)}); localStorage.removeItem(${JSON.stringify(aiApiKeySecretKey)}); localStorage.removeItem(${JSON.stringify(permissionSettingsKey)}); localStorage.removeItem(${JSON.stringify(analyticsLayoutKey)}); localStorage.removeItem(${JSON.stringify(analyticsExportKey)}); localStorage.removeItem(${JSON.stringify(analyticsDrilldownKey)}); localStorage.removeItem(${JSON.stringify(analyticsViewStateKey)}); localStorage.removeItem(${JSON.stringify(issuePoolExportKey)}); localStorage.removeItem(${JSON.stringify(issueProcessExportKey)}); localStorage.removeItem(${JSON.stringify(departmentQueueSettingsKey)}); localStorage.removeItem(${JSON.stringify(knowledgeReviewSettingsKey)}); localStorage.removeItem(${JSON.stringify(workbenchActionKey)}); localStorage.removeItem(${JSON.stringify(handlerActionKey)}); true;`);
await navigate(client, `${appOrigin}/?capture=${Date.now()}#/center-dashboard`);
for (const page of implementationPages) {
await setViewport(client, page.width, page.height, page.mobile);
await navigate(client, `${appOrigin}/#/${page.route}`);
await waitForText(client, page.waitFor);
await delay(150);
await captureToFile(client, path.join(pageArtifactDir, page.fileName));
log(`Captured ${page.fileName}.`);
}
console.log("Implementation screenshots captured.");
} finally {
chrome.kill();
if (devServer) devServer.kill();
await cleanupDirectory(chromeUserDataDir);
}
}
const implementationPages = [
{ fileName: "dispatcher-01-center-dashboard.png", route: "center-dashboard", waitFor: "中心调度台", width: 1672, height: 941, mobile: false },
{ fileName: "dispatcher-02-issue-pool.png", route: "issue-pool", waitFor: "问题池", width: 1672, height: 941, mobile: false },
{ fileName: "dispatcher-03-department-queue.png", route: "department-queue", waitFor: "部门队列", width: 1672, height: 941, mobile: false },
{ fileName: "dispatcher-04-escalation-center.png", route: "escalation-center", waitFor: "提醒升级", width: 1672, height: 941, mobile: false },
{ fileName: "dispatcher-05-knowledge-review.png", route: "knowledge-review", waitFor: "知识复盘", width: 1672, height: 941, mobile: false },
{ fileName: "dispatcher-06-rule-config.png", route: "rule-config", waitFor: "规则配置", width: 1672, height: 941, mobile: false },
{ fileName: "dispatcher-07-analytics-board.png", route: "analytics-board", waitFor: "管理看板", width: 1672, height: 941, mobile: false },
{ fileName: "client-01-workbench.png", route: "client-workbench", waitFor: "我的待办", width: 1672, height: 941, mobile: false },
{ fileName: "client-02-submit-issue.png", route: "submit-issue", waitFor: "提交问题", width: 1672, height: 941, mobile: false },
{ fileName: "client-03-issue-detail-handler.png", route: "handler-detail", waitFor: "问题详情", width: 1672, height: 941, mobile: false },
{ fileName: "client-04-submitter-tracking.png", route: "submitter-tracking", waitFor: "进度追踪", width: 1622, height: 969, mobile: false },
{ fileName: "client-05-mobile-submit.png", route: "mobile-submit", waitFor: "移动端提交", width: 1536, height: 1024, mobile: false },
{ fileName: "client-05-mobile-submit-mobile.png", route: "mobile-submit", waitFor: "提交到中心", width: 390, height: 920, mobile: true },
{ fileName: "client-02-submit-issue-mobile.png", route: "submit-issue", waitFor: "提交问题", width: 430, height: 932, mobile: true },
{ fileName: "dispatcher-02-issue-pool-mobile.png", route: "issue-pool", waitFor: "问题池", width: 430, height: 932, mobile: true }
];
async function runIssuePoolSmoke(client) {
await navigate(client, `${appOrigin}/#/issue-pool`);
await evaluate(client, `localStorage.removeItem(${JSON.stringify(storageKey)}); localStorage.removeItem(${JSON.stringify(syncSettingsKey)}); localStorage.removeItem(${JSON.stringify(syncTokenKey)}); localStorage.removeItem(${JSON.stringify(aiSettingsKey)}); localStorage.removeItem(${JSON.stringify(aiApiKeySecretKey)}); localStorage.removeItem(${JSON.stringify(permissionSettingsKey)}); localStorage.removeItem(${JSON.stringify(analyticsLayoutKey)}); localStorage.removeItem(${JSON.stringify(analyticsExportKey)}); localStorage.removeItem(${JSON.stringify(analyticsDrilldownKey)}); localStorage.removeItem(${JSON.stringify(analyticsViewStateKey)}); localStorage.removeItem(${JSON.stringify(issuePoolExportKey)}); localStorage.removeItem(${JSON.stringify(issueProcessExportKey)}); localStorage.removeItem(${JSON.stringify(departmentQueueSettingsKey)}); localStorage.removeItem(${JSON.stringify(knowledgeReviewSettingsKey)}); localStorage.removeItem(${JSON.stringify(workbenchActionKey)}); localStorage.removeItem(${JSON.stringify(handlerActionKey)}); location.reload();`);
await waitForText(client, "问题池");
await waitForText(client, "IH-2026-0144");
await clickButton(client, "5 条/页");
await waitForExpression(
client,
`Array.from(document.querySelectorAll(".issue-pool-page-size button.active")).some((button) => button.textContent?.trim() === "5 条/页")`,
5_000,
"Issue pool page size did not switch to 5."
);
await waitForText(client, "显示 1-5");
const issuePoolFirstPageFirstIssue = await evaluateJson(
client,
`document.querySelector(".issue-pool-page .data-table-row strong")?.textContent?.trim() || ""`
);
await clickButtonByAriaLabel(client, "问题池下一页");
await waitForExpression(
client,
`document.querySelector(".issue-pool-page-numbers button.active")?.textContent?.trim() === "2"`,
5_000,
"Issue pool pagination did not activate page 2."
);
await waitForText(client, "显示 6-");
const issuePoolSecondPageFirstIssue = await evaluateJson(
client,
`document.querySelector(".issue-pool-page .data-table-row strong")?.textContent?.trim() || ""`
);
assert(issuePoolFirstPageFirstIssue && issuePoolSecondPageFirstIssue && issuePoolFirstPageFirstIssue !== issuePoolSecondPageFirstIssue, "Issue pool pagination did not change visible issue rows.");
await clickButton(client, "20 条/页");
await waitForExpression(
client,
`document.querySelector(".issue-pool-page-numbers button.active")?.textContent?.trim() === "1" && Array.from(document.querySelectorAll(".issue-pool-page-size button.active")).some((button) => button.textContent?.trim() === "20 条/页")`,
5_000,
"Issue pool page size did not switch to 20 and return to page 1."
);
await waitForText(client, "显示 1-");
await clickButton(client, "导出");
await waitForText(client, "已导出");
const issuePoolExportRecord = await evaluateJson(client, `JSON.parse(localStorage.getItem(${JSON.stringify(issuePoolExportKey)}) || "{}")`);
assert(issuePoolExportRecord.fileName?.startsWith("issue-hub-issues-"), "Issue pool export file name was not persisted.");
assert(Number.isFinite(issuePoolExportRecord.rowCount) && issuePoolExportRecord.rowCount > 0, "Issue pool export row count was not persisted.");
assert(issuePoolExportRecord.filterLabel === "全部问题", "Issue pool export filter label was not persisted.");
await clickButton(client, "提交人: 全部");
await waitForText(client, "已筛选问题池提交人:张阳");
await clickButton(client, "导出");
const issuePoolSubmitterExportRecord = await evaluateJson(client, `JSON.parse(localStorage.getItem(${JSON.stringify(issuePoolExportKey)}) || "{}")`);
assert(issuePoolSubmitterExportRecord.filterLabel === "提交人:已限定", `Issue pool submitter filter label was not persisted. Got ${issuePoolSubmitterExportRecord.filterLabel}`);
assert(issuePoolSubmitterExportRecord.rowCount < issuePoolExportRecord.rowCount, "Issue pool submitter filter did not reduce exported rows.");
await clickButton(client, "提交人: 张阳");
await waitForText(client, "已筛选问题池提交人:赵敏");
await waitForText(client, "显示 1-2 / 共 2 条");
await clickButton(client, "提交人: 赵敏");
await waitForText(client, "已筛选问题池提交人:全部");
await clickButton(client, "状态: 全部");
await waitForText(client, "已筛选问题池状态:未关闭");
await clickButton(client, "优先级: 全部");
await waitForText(client, "已筛选问题池优先级:P0");
await clickButton(client, "部门: 全部");
await waitForText(client, "已筛选问题池部门:");
await clickButton(client, "来源: 全部");
await waitForText(client, "已筛选问题池来源:");
await clickButton(client, "SLA: 全部");
await waitForText(client, "已筛选问题池 SLA:已超时");
await clickButton(client, "提交时间: 由新到旧");
await waitForText(client, "问题池提交时间已按由旧到新排序");
await setSearch(client, "不存在的客户编码");
await waitForText(client, "没有匹配的问题");
await clickButton(client, "批量派发");
await waitForText(client, "请先选择要派发的问题。");
await capture(client, "issue-pool-empty-search.png");
}
async function runGlobalSyncStatusSmoke(client, mockSyncOrigin, mockAiOrigin) {
await navigate(client, `${appOrigin}/#/center-dashboard`);
await waitForText(client, "系统状态");
await waitForText(client, "本地演示");
await clickButton(client, "立即同步");
await waitForText(client, "同步服务未配置");
await capture(client, "global-sync-local-mode.png");
await clickButtonByAriaLabel(client, "打开通知中心");
await waitForText(client, "待处理提醒");
await waitForText(client, "SLA 风险");
await capture(client, "global-notifications-panel.png");
await clickButtonByAriaLabel(client, "关闭全局工具面板");
await waitForTextMissing(client, "待处理提醒");
await clickButtonByAriaLabel(client, "打开帮助面板");
await waitForText(client, "问题流转助手");
await waitForText(client, "角色流程");
await capture(client, "global-help-panel.png");
await clickButtonByAriaLabel(client, "关闭全局工具面板");
await waitForTextMissing(client, "问题流转助手");
await clickButton(client, "设置");
await waitForText(client, "平台设置");
await setSettingsField(client, "服务端地址", mockSyncOrigin);
await setSettingsField(client, "访问 Token", "demo-token");
await toggleSwitch(client, "启用服务端同步");
await clickButton(client, "保存同步配置");
await waitForText(client, "同步配置已保存");
const savedSyncSettings = await evaluateJson(
client,
`(() => ({
settings: JSON.parse(localStorage.getItem(${JSON.stringify(syncSettingsKey)}) || "{}"),
token: localStorage.getItem(${JSON.stringify(syncTokenKey)}) || ""
}))()`
);
assert(savedSyncSettings.settings.enabled === true, "Sync settings were not enabled after save.");
assert(savedSyncSettings.settings.serverUrl === mockSyncOrigin, `Sync server URL was not saved. Got ${savedSyncSettings.settings.serverUrl}`);
assert(savedSyncSettings.token === "demo-token", "Sync token was not saved.");
const visibleSyncUrl = await readSettingsField(client, "服务端地址");
assert(visibleSyncUrl === mockSyncOrigin, `Visible sync URL did not match saved URL. Got ${visibleSyncUrl}`);
await clickButton(client, "测试连接");
const syncTestMessage = await waitForAnyText(client, ["同步服务连接正常", "请先启用同步", "同步服务健康检查失败", "同步服务连接失败"]);
assert(syncTestMessage === "同步服务连接正常", `Unexpected sync test feedback: ${syncTestMessage}`);
await clickButton(client, "立即同步");
await waitForText(client, "同步完成");
await waitForStoredState(client, (state) => state.issues?.some((issue) => issue.id === "issue-sync-remote") && state.timeline?.some((event) => event.id === "tl-sync-remote"));
await capture(client, "settings-sync-success.png");
await clickButton(client, "AI 能力");
await setSettingsField(client, "Base URL", mockAiOrigin);
await setSettingsField(client, "采纳阈值", "88");
await setSettingsField(client, "API Key", "sk-demo-key");
await clickButton(client, "保存 AI 配置");
await waitForText(client, "AI 配置已保存");
await clickButton(client, "检查 AI 配置");
await waitForText(client, "AI 配置检查通过");
await clickButton(client, "提醒策略");
await setSettingsField(client, "重复提醒间隔", "25");
await clickButton(client, "保存提醒策略");
await waitForText(client, "提醒策略已保存");
const savedNotificationSettings = await evaluateJson(
client,
`(() => JSON.parse(localStorage.getItem(${JSON.stringify(notificationSettingsKey)}) || "{}"))()`
);
assert(savedNotificationSettings.repeatEveryMinutes === 25, "Notification repeat interval was not saved.");
assert(savedNotificationSettings.channels?.inApp === true, "In-app notification channel was not saved.");
await capture(client, "settings-panel-configured.png");
await clickButton(client, "权限角色");
await togglePermission(client, "运营观察员", "规则配置");
await setSettingsField(client, "审计日志留存天数", "365");
await clickButton(client, "保存权限配置");
await waitForText(client, "权限角色配置已保存");
const savedPermissionSettings = await evaluateJson(
client,
`(() => JSON.parse(localStorage.getItem(${JSON.stringify(permissionSettingsKey)}) || "{}"))()`
);
const auditorRole = savedPermissionSettings.roles?.find((role) => role.id === "role-auditor");
assert(auditorRole?.permissions?.includes("manage_rules"), "Auditor role did not persist the rules permission.");
assert(savedPermissionSettings.auditTrailRetentionDays === 365, "Permission audit retention did not persist.");
await capture(client, "settings-permission-roles.png");
await togglePermission(client, "平台管理员", "规则配置", false);
await clickButton(client, "保存权限配置");
await waitForText(client, "权限角色配置已保存");
await clickButton(client, "关闭");
await clickButton(client, "规则配置");
await waitForText(client, "缺少「规则配置」权限");
await clickButton(client, "设置");
await waitForText(client, "平台设置");
await clickButton(client, "权限角色");
await togglePermission(client, "平台管理员", "规则配置");
await clickButton(client, "保存权限配置");
await waitForText(client, "权限角色配置已保存");
await clickButton(client, "关闭");
}
async function runLiveAiTriageSmoke(client) {
await navigate(client, `${appOrigin}/#/center-dashboard`);
await waitForText(client, "中心调度台");
await setSearch(client, "订单支付成功");
await waitForText(client, "订单支付成功但状态未更新");
await clickButton(client, "AI 重新分析");
await waitForText(client, "AI 重新分析完成");
await waitForStoredState(client, (state) => {
const triage = state.triageResults?.find((item) => item.issueId === "issue-001");
return (
triage?.id?.startsWith("ai-live-") &&
triage.summary === "mock AI 识别为支付回调状态同步异常。" &&
state.timeline?.some((event) => event.issueId === "issue-001" && event.title === "AI 重新分析完成")
);
});
await clickButton(client, "退回补充");
await waitForText(client, "退回补充要求");
await clickButton(client, "取消");
await capture(client, "center-ai-live-triage.png");
await clickButton(client, "设置");
await waitForText(client, "平台设置");
await clickButton(client, "AI 能力");
await clickButton(client, "清除 Key");
await waitForText(client, "AI API Key 已清除");
await clickButton(client, "关闭");
}
async function runIssuePoolBulkSmoke(client) {
await navigate(client, `${appOrigin}/#/issue-pool`);
await waitForText(client, "问题池");
await setSearch(client, "库存扣减失败");
await waitForText(client, "库存扣减失败导致超卖预警");
await clickCheckboxInRegion(client, "库存扣减失败导致超卖预警");
await waitForText(client, "已选择 1 项");
await clickButton(client, "批量派发");
await waitForIssueState(client, "issue-003", { status: "assigned_to_user", timelineTitle: "批量采纳 AI 派发" });
await capture(client, "issue-pool-bulk-dispatched.png");
await clickButton(client, "更多操作");
await waitForText(client, "批量治理");
await clickButton(client, "导入标签");
await waitForStoredState(client, (state) => state.issues?.find((issue) => issue.id === "issue-003")?.tags?.includes("批量关注") && state.timeline?.some((event) => event.issueId === "issue-003" && event.title === "批量导入标签"));
await capture(client, "issue-pool-bulk-tagged.png");
}
async function runClientWorkbenchSmoke(client) {
await navigate(client, `${appOrigin}/#/client-workbench`);
await waitForText(client, "我的待办");
await setSearch(client, "");
await waitForText(client, "AI 摘要");
await clickButtonByAriaLabel(client, "刷新待办列表");
await waitForText(client, "待办列表已刷新");
await clickButton(client, "待接单");
await waitForText(client, "已筛选我的待办:待接单");
await clickButton(client, "全部");
await waitForText(client, "已筛选我的待办:全部");
await clickButton(client, "优先级:全部");
await waitForText(client, "按优先级筛选");
await clickButton(client, "P1");
await waitForText(client, "已筛选优先级:P1");
await clickButton(client, "排序:SLA 升序");
await waitForText(client, "SLA 排序");
await clickButton(client, "SLA 降序");
await waitForText(client, "已按 SLA 降序排序");
await clickButton(client, "优先级:P1");
await waitForText(client, "按优先级筛选");
await clickButton(client, "全部优先级");
await waitForText(client, "已筛选优先级:全部");
await clickButton(client, "5 条/页");
await waitForText(client, "我的待办每页显示 5 条");
const firstPageFirstIssue = await evaluateJson(
client,
`document.querySelector(".workbench-table article .workbench-row-title strong")?.textContent?.trim() || ""`
);
await clickButtonByAriaLabel(client, "我的待办下一页");
await waitForExpression(
client,
`document.querySelector(".workbench-pagination button.active")?.textContent?.trim() === "2"`,
5_000,
"Workbench pagination did not activate page 2."
);
await waitForText(client, "显示 6-");
const secondPageFirstIssue = await evaluateJson(
client,
`document.querySelector(".workbench-table article .workbench-row-title strong")?.textContent?.trim() || ""`
);
assert(firstPageFirstIssue && secondPageFirstIssue && firstPageFirstIssue !== secondPageFirstIssue, "Workbench pagination did not change visible issue rows.");
await clickButton(client, "20 条/页");
await waitForText(client, "我的待办每页显示 20 条");
await waitForExpression(
client,
`document.querySelector(".workbench-pagination button.active")?.textContent?.trim() === "1" && Array.from(document.querySelectorAll(".workbench-pagination button.active")).some((button) => button.textContent?.trim() === "20 条/页")`,
5_000,
"Workbench page size did not switch to 20 and return to page 1."
);
await waitForText(client, "显示 1-");
await clickButton(client, "查看分析详情");
await waitForText(client, "分析详情");
await clickButton(client, "收起分析详情");
await clickButtonByAriaLabel(client, "更多处理动作");
await waitForText(client, "标记关注");
await clickButton(client, "复制链接");
await waitForText(client, "已复制");
const copiedWorkbenchLink = await evaluateJson(client, `JSON.parse(localStorage.getItem(${JSON.stringify(workbenchActionKey)}) || "{}").copiedLinks?.[0] || null`);
assert(copiedWorkbenchLink?.issueCode, "Workbench copied link record was not persisted.");
assert(copiedWorkbenchLink.href?.includes("#/handler-detail?issue="), "Workbench copied link href was not persisted.");
await clickButton(client, "处理记录");
await waitForText(client, "当前处理状态");
await clickButton(client, "相关信息");
await waitForText(client, "相似问题");
await clickButtonByAriaLabel(client, "更多行操作");
await waitForText(client, "标记关注");
await clickButton(client, "标记关注");
await waitForText(client, "已关注");
const followedWorkbenchIssues = await evaluateJson(client, `JSON.parse(localStorage.getItem(${JSON.stringify(workbenchActionKey)}) || "{}").followedIssueIds || []`);
assert(Array.isArray(followedWorkbenchIssues) && followedWorkbenchIssues.length > 0, "Workbench followed issue was not persisted.");
await clickButtonByAriaLabel(client, "更多问题操作");
await waitForText(client, "取消关注");
await clickButton(client, "返回列表");
await waitForText(client, "已返回列表");
await capture(client, "client-workbench-detail-tabs.png");
}
async function runSubmitAndPersistenceSmoke(client) {
const draftTitle = "草稿恢复-门店日报字段缺失";
const draftDescription = "草稿恢复描述:财务复核时仍缺少库存损耗字段。";
await navigate(client, `${appOrigin}/#/submit-issue`);
await waitForText(client, "提交问题");
await setSubmitField(client, "问题标题", draftTitle);
await setFirstTextareaValue(client, draftDescription);
await clickButton(client, "重新检测");
await waitForText(client, "结果已刷新");
await waitForText(client, "模板版本或导出批次");
await waitForText(client, "置信度 91%");
await waitForExpression(client, `JSON.parse(localStorage.getItem(${JSON.stringify(submitPrecheckKey)}) || "{}").runs?.[0]?.missingItem === "模板版本或导出批次"`);
await clickButton(client, "补充");
await waitForTextareaValue(client, "模板版本或导出批次");
await clickButton(client, "选择其他分类");
await waitForText(client, "库存履约 > 库存损耗");
await clickButtonInSelector(client, ".submit-category-options", "库存履约 > 库存损耗");
await waitForSubmitFieldValue(client, "所属系统", "库存/履约");
await clickButton(client, "查看流转规则");
await waitForText(client, "流转规则详情");
await waitForText(client, "订单支付类转研发订单组");
await waitForText(client, "研发部");
await clickButton(client, "收起流转规则");
await waitForTextMissing(client, "流转规则详情");
await clickButtonByAriaLabel(client, "表格");
await waitForTextareaValue(client, "| 字段 | 当前值 | 期望值 |");
await waitForText(client, "描述编辑工具已应用:表格");
await clickButtonByAriaLabel(client, "桌面提交附件上传");
await waitForText(client, "已上传日报截图.png");
await waitForText(client, "桌面端已上传 1 个问题附件");
await setSubmitField(client, "问题来源", "企业微信");
await setSubmitField(client, "所属系统", "订单/支付");
await setSubmitField(client, "期望解决时间", "4");
await clickButtonByAriaLabel(client, "影响范围-全公司");
await clickButtonByAriaLabel(client, "影响等级-高");
await clickButton(client, "保存草稿");
await waitForText(client, "草稿已保存,可在下次打开提交页继续编辑。");
await waitForStoredState(client, (state) =>
state.issueDraft?.title === draftTitle &&
state.issueDraft?.description?.includes(draftDescription) &&
state.issueDraft?.source === "企业微信" &&
state.issueDraft?.category === "订单/支付" &&
state.issueDraft?.impactScope === "全公司" &&
state.issueDraft?.severity === "high" &&
state.issueDraft?.expectedHours === 4 &&
state.issueDraft?.attachments?.[0]?.name === "已上传日报截图.png" &&
state.issueDraft?.attachments?.[0]?.dataUrl?.startsWith("data:image/png")
);
await evaluate(client, `location.reload();`);
await waitForText(client, "提交问题");
await navigate(client, `${appOrigin}/#/submit-issue`);
await waitForSubmitFieldValue(client, "问题标题", draftTitle);
await waitForTextareaValue(client, draftDescription);
await waitForSubmitFieldValue(client, "问题来源", "企业微信");
await waitForSubmitFieldValue(client, "所属系统", "订单/支付");
await waitForSubmitFieldValue(client, "期望解决时间", "4");
await capture(client, "submit-issue-editor-attachment.png");
await clickButton(client, "提交到中心");
await waitForLocationHash(client, "#/submitter-tracking");
await waitForText(client, "问题已提交到中心");
await waitForStoredState(client, (state) =>
state.issueDraft === null &&
state.issues?.[0]?.title === draftTitle &&
state.issues?.[0]?.description?.includes("模板版本或导出批次") &&
state.issues?.[0]?.source === "企业微信" &&
state.issues?.[0]?.category === "订单/支付" &&
state.issues?.[0]?.impactScope === "全公司" &&
state.issues?.[0]?.severity === "high" &&
state.issues?.[0]?.slaMinutes === 240 &&
state.issues?.[0]?.attachments?.[0]?.name === "已上传日报截图.png" &&
state.issues?.[0]?.attachments?.[0]?.dataUrl?.startsWith("data:image/png")
);
const persisted = await evaluateJson(
client,
`(() => {
const data = JSON.parse(localStorage.getItem(${JSON.stringify(storageKey)}) || "{}");
return {
selectedIssueId: data.selectedIssueId,
firstTitle: data.issues?.[0]?.title,
firstDescription: data.issues?.[0]?.description,
firstSource: data.issues?.[0]?.source,
firstCategory: data.issues?.[0]?.category,
firstImpactScope: data.issues?.[0]?.impactScope,
firstSeverity: data.issues?.[0]?.severity,
firstSlaMinutes: data.issues?.[0]?.slaMinutes,
firstSlaRemainingMinutes: data.issues?.[0]?.slaRemainingMinutes,
firstAttachmentName: data.issues?.[0]?.attachments?.[0]?.name,
firstAttachmentDataUrl: data.issues?.[0]?.attachments?.[0]?.dataUrl,
firstTimelineTitle: data.timeline?.[0]?.title,
firstTriageIssueId: data.triageResults?.[0]?.issueId,
issueDraft: data.issueDraft
};
})()`
);
assert(persisted.firstTitle === draftTitle, "Submitted issue was not first in persisted issue list.");
assert(persisted.firstDescription?.includes("模板版本或导出批次"), "Submitted issue did not persist AI supplement suggestion.");
assert(persisted.firstDescription?.includes("| 字段 | 当前值 | 期望值 |"), "Submitted issue did not persist rich editor toolbar content.");
assert(persisted.firstSource === "企业微信", `Submitted issue source did not persist selected value. Got ${persisted.firstSource}`);
assert(persisted.firstCategory === "订单/支付", `Submitted issue category did not persist selected value. Got ${persisted.firstCategory}`);
assert(persisted.firstImpactScope === "全公司", `Submitted issue impact scope did not persist selected value. Got ${persisted.firstImpactScope}`);
assert(persisted.firstSeverity === "high", `Submitted issue severity did not persist selected value. Got ${persisted.firstSeverity}`);
assert(persisted.firstSlaMinutes === 240 && persisted.firstSlaRemainingMinutes === 240, `Submitted issue expected hours did not persist to SLA minutes. Got ${persisted.firstSlaMinutes}/${persisted.firstSlaRemainingMinutes}`);
assert(persisted.firstAttachmentName === "已上传日报截图.png", `Submitted issue attachment name did not persist. Got ${persisted.firstAttachmentName}`);
assert(persisted.firstAttachmentDataUrl?.startsWith("data:image/png"), "Submitted issue attachment dataUrl did not persist.");
assert(persisted.firstTimelineTitle === "提交问题", "Submitted issue timeline was not persisted.");
assert(persisted.firstTriageIssueId === persisted.selectedIssueId, "Submitted issue triage result did not target the selected issue.");
await setSearch(client, "");
assert(persisted.issueDraft === null, "Submit issue draft was not cleared after successful submit.");
await waitForText(client, draftTitle);
await waitForText(client, "附件(1");
await waitForText(client, "已上传日报截图.png");
await clickButton(client, "全部下载");
await waitForText(client, "已记录下载");
await waitForStoredState(client, (_state) => true);
const submitterDownload = await evaluateJson(
client,
`(() => JSON.parse(localStorage.getItem(${JSON.stringify(attachmentDownloadKey)}) || "[]")[0] || null)()`
);
assert(submitterDownload?.source === "submitter", `Submitter download record source mismatch. Got ${submitterDownload?.source}`);
assert(submitterDownload?.count === 1, `Submitter download record count mismatch. Got ${submitterDownload?.count}`);
assert(submitterDownload?.attachmentNames?.[0] === "已上传日报截图.png", "Submitter download record did not include submitted attachment name.");
await capture(client, "submitter-tracking-after-submit.png");
await navigate(client, `${appOrigin}/#/handler-detail`);
await waitForText(client, "问题详情");
await waitForText(client, "附件(1");
await waitForText(client, "已上传日报截图.png");
await clickButton(client, "全部下载");
await waitForText(client, "已记录下载");
const handlerDownload = await evaluateJson(
client,
`(() => JSON.parse(localStorage.getItem(${JSON.stringify(attachmentDownloadKey)}) || "[]")[0] || null)()`
);
assert(handlerDownload?.source === "handler", `Handler download record source mismatch. Got ${handlerDownload?.source}`);
assert(handlerDownload?.issueId === persisted.selectedIssueId, "Handler download record did not target the submitted issue.");
}
async function runOperationalWorkflowSmoke(client) {
await navigate(client, `${appOrigin}/#/center-dashboard`);
await waitForText(client, "中心调度台");
await setSearch(client, "");
await clickButton(client, "3 条/页");
await waitForExpression(
client,
`Array.from(document.querySelectorAll(".center-page-size button.active")).some((button) => button.textContent?.trim() === "3 条/页")`,
5_000,
"Center dashboard page size did not switch to 3."
);
await waitForText(client, "显示 1-");
const centerFirstPageFirstIssue = await evaluateJson(
client,
`document.querySelector(".center-dashboard-page .data-table-row .table-title strong")?.textContent?.trim() || ""`
);
await clickButtonByAriaLabel(client, "中心调度下一页");
await waitForExpression(
client,
`document.querySelector(".center-page-numbers button.active")?.textContent?.trim() === "2"`,
5_000,
"Center dashboard pagination did not activate page 2."
);
await waitForText(client, "显示 4-");
const centerSecondPageFirstIssue = await evaluateJson(
client,
`document.querySelector(".center-dashboard-page .data-table-row .table-title strong")?.textContent?.trim() || ""`
);
assert(centerFirstPageFirstIssue && centerSecondPageFirstIssue && centerFirstPageFirstIssue !== centerSecondPageFirstIssue, "Center dashboard pagination did not change visible issue rows.");
await clickButton(client, "20 条/页");
await waitForExpression(
client,
`document.querySelector(".center-page-numbers button.active")?.textContent?.trim() === "1" && Array.from(document.querySelectorAll(".center-page-size button.active")).some((button) => button.textContent?.trim() === "20 条/页")`,
5_000,
"Center dashboard page size did not switch to 20 and return to page 1."
);
await waitForText(client, "显示 1-");
await setSearch(client, "订单支付成功");
await waitForText(client, "订单支付成功但状态未更新");
await clickButton(client, "筛选");
await waitForText(client, "筛选条件");
await clickButton(client, "高风险");
await clickButton(client, "应用筛选");
await waitForText(client, "已应用中心筛选");
await clickButtonByAriaLabel(client, "IH-2026-0148 更多派发操作");
await waitForText(client, "派发操作:改派部门");
await waitForText(client, "AI 依据");
await waitForText(client, "要求补充");
await clickButtonByAriaLabel(client, "关闭派发操作面板");
await waitForTextMissing(client, "派发操作:改派部门");
await clickCheckboxInRegion(client, "订单支付成功但状态未更新");
await clickButton(client, "批量操作");
await waitForText(client, "已选择 1 个问题");
await clickButton(client, "批量采纳 AI 派发");
await waitForIssueState(client, "issue-001", { status: "assigned_to_user", timelineTitle: "批量采纳 AI 派发" });
await capture(client, "center-ai-dispatch-accepted.png");
await navigate(client, `${appOrigin}/#/department-queue`);
await waitForText(client, "部门队列");
await setSearch(client, "");
await waitForText(client, "显示 1-");
const departmentFirstPageFirstIssue = await evaluateJson(
client,
`document.querySelector(".department-queue-page .data-table-row strong")?.textContent?.trim() || ""`
);
await clickButtonByAriaLabel(client, "部门队列下一页");
await waitForExpression(
client,
`document.querySelector(".department-page-numbers button.active")?.textContent?.trim() === "2"`,
5_000,
"Department queue pagination did not activate page 2."
);
await waitForText(client, "显示 6-");
const departmentSecondPageFirstIssue = await evaluateJson(
client,
`document.querySelector(".department-queue-page .data-table-row strong")?.textContent?.trim() || ""`
);
assert(departmentFirstPageFirstIssue && departmentSecondPageFirstIssue && departmentFirstPageFirstIssue !== departmentSecondPageFirstIssue, "Department queue pagination did not change visible issue rows.");
await clickButton(client, "20 条/页");
await waitForExpression(
client,
`document.querySelector(".department-page-numbers button.active")?.textContent?.trim() === "1" && Array.from(document.querySelectorAll(".department-page-size button.active")).some((button) => button.textContent?.trim() === "20 条/页")`,
5_000,
"Department queue page size did not switch to 20 and return to page 1."
);
await waitForText(client, "显示 1-");
await clickButton(client, "状态: 全部");
await waitForText(client, "已筛选部门队列状态:部门待分派");
await clickButton(client, "优先级: all");
await waitForText(client, "已筛选部门队列优先级:P1");
await clickButton(client, "SLA: 全部");
await waitForText(client, "已筛选部门队列 SLA:已超时");
await clickButton(client, "创建时间: 由新到旧");
await waitForText(client, "部门队列创建时间已按由旧到新排序");
await clickButton(client, "状态: 部门待分派");
await clickButton(client, "状态: 处理中");
await clickButton(client, "状态: 等待中");
await clickButton(client, "状态: 待确认");
await clickButton(client, "优先级: P1");
await clickButton(client, "优先级: P2");
await clickButton(client, "优先级: P3");
await clickButton(client, "SLA: 已超时");
await clickButton(client, "SLA: 120分钟内");
await clickButton(client, "SLA: 正常");
await clickButton(client, "创建时间: 由旧到新");
await clickButton(client, "队列设置");
await waitForText(client, "负责人轮询");
await clickCheckboxByLabel(client, "技能标签优先");
await clickButton(client, "取消");
await clickButton(client, "队列设置");
await assertCheckboxByLabel(client, "技能标签优先", true);
await clickCheckboxByLabel(client, "技能标签优先");
await clickButton(client, "保存设置");
await waitForText(client, "队列设置已保存");
const departmentQueueSettings = await evaluateJson(client, `JSON.parse(localStorage.getItem(${JSON.stringify(departmentQueueSettingsKey)}) || "{}")`);
assert(departmentQueueSettings["dept-rd"]?.skillFirst === false, "Department queue setting skillFirst was not persisted.");
await evaluate(client, "location.reload(); true;");
await waitForText(client, "部门队列");
await clickButton(client, "队列设置");
await assertCheckboxByLabel(client, "技能标签优先", false);
await clickButton(client, "取消");
await clickButton(client, "查看分析详情");
await waitForText(client, "分派分析详情");
await clickButton(client, "收起分析详情");
await clickButton(client, "更多成员");
await waitForText(client, "成员负载明细");
await waitForExpression(client, `document.querySelector(".member-detail-panel select")?.value === "all"`);
await setFirstSelectValue(client, ".member-detail-panel select", "库存");
await waitForExpression(client, `document.querySelector(".member-detail-panel select")?.value === "库存"`);
await waitForExpression(client, `Array.from(document.querySelectorAll(".member-detail-row")).length > 0 && Array.from(document.querySelectorAll(".member-detail-row")).every((row) => row.textContent.includes("库存"))`);
await clickButton(client, "响应最快");
await waitForExpression(client, `Array.from(document.querySelectorAll(".member-detail-controls button.active")).some((button) => button.textContent?.includes("响应最快"))`);
await clickButtonInSelector(client, ".member-detail-panel", "分派");
await waitForStoredState(client, (state) => state.issues?.find((issue) => issue.id === state.selectedIssueId)?.assigneeId === "u-li");
await clickButtonByAriaLabel(client, "关闭成员负载明细");
await waitForTextMissing(client, "成员负载明细");
await setSearch(client, "订单支付成功");
await waitForText(client, "订单支付成功但状态未更新");
await clickButton(client, "请求中心介入");
await waitForText(client, "已升级负责人");
await waitForStoredState(client, (state) =>
state.timeline?.some((event) => event.issueId === "issue-001" && event.title === "升级负责人") &&
state.escalations?.some((event) => event.issueId === "issue-001" && event.handled !== true)
);
await capture(client, "department-center-escalated.png");
await clickButton(client, "分派处理人");
await waitForIssueState(client, "issue-001", { status: "assigned_to_user", timelineTitle: "部门分派处理人" });
await capture(client, "department-assigned-handler.png");
await navigate(client, `${appOrigin}/#/handler-detail`);
await waitForText(client, "问题详情");
await waitForText(client, "订单支付成功但状态未更新");
await clickButtonByAriaLabel(client, "打开通知中心");
await waitForText(client, "待处理提醒");
await clickButtonByAriaLabel(client, "关闭全局工具面板");
await waitForTextMissing(client, "待处理提醒");
await clickButtonByAriaLabel(client, "打开帮助面板");
await waitForText(client, "问题流转助手");
await clickButtonByAriaLabel(client, "关闭全局工具面板");
await waitForTextMissing(client, "问题流转助手");
await clickButton(client, "关联信息");
await waitForText(client, "同类问题 7 天内出现 4 次");
await clickButton(client, "SLA 详情");
await waitForText(client, "升级策略");
await clickButton(client, "处理过程");
await waitForText(client, "修复完成后先送测,复测通过后按需发布更新文件");
await clickButton(client, "@ 客户评论");
await waitForExpression(client, `Array.from(document.querySelectorAll("textarea")).some((item) => item.placeholder.includes("发送前会同步给提交人"))`);
await clickButtonByAriaLabel(client, "附件");
await waitForTextareaValue(client, "[附件:处理日志.zip]");
await waitForText(client, "已插入评论工具:附件");
await clickButtonByAriaLabel(client, "图片");
await waitForTextareaValue(client, "![处理截图.png](attachment://handler-screenshot");
await clickButtonByAriaLabel(client, "链接");
await waitForTextareaValue(client, "[处理链路](#/handler-detail?issue=issue-001)");
await waitForExpression(client, `JSON.parse(localStorage.getItem(${JSON.stringify(handlerActionKey)}) || "{}").commentTools?.some((record) => record.issueCode === "IH-2026-0148" && record.tool === "链接")`);
await waitForStoredState(client, (state) => {
const issue = state.issues?.find((item) => item.id === "issue-001");
return issue?.attachments?.some((attachment) => attachment.name === "处理日志.zip" && attachment.type === "application/zip") &&
issue.attachments.some((attachment) => attachment.name === "处理截图.png" && attachment.type === "image/png");
});
await clickButton(client, "内部评论");
await waitForExpression(client, `Array.from(document.querySelectorAll("textarea")).some((item) => item.placeholder.includes("Ctrl + Enter 快速发送"))`);
await capture(client, "handler-tabs-comment-tools.png");
await clickButton(client, "接单处理");
await waitForIssueState(client, "issue-001", { status: "in_progress", timelineTitle: "处理人接单" });
await clickButton(client, "请求协作");
await waitForIssueState(client, "issue-001", { status: "waiting_collaborator", timelineTitle: "请求协作" });
await setTextareaByPlaceholder(client, "请输入评论内容", "已确认回调消费延迟,正在补充队列重放脚本。\n[附件:处理日志.zip]\n![处理截图.png](attachment://handler-screenshot)\n[处理链路](#/handler-detail?issue=issue-001)");
await clickButton(client, "发送");
await waitForIssueState(client, "issue-001", { status: "waiting_collaborator", timelineTitle: "处理人评论" });
await waitForStoredState(client, (state) => state.timeline?.some((event) => event.issueId === "issue-001" && event.title === "处理人评论" && event.description.includes("[处理链路](#/handler-detail?issue=issue-001)")));
await clickButton(client, "关联任务");
await waitForIssueState(client, "issue-001", { status: "waiting_collaborator", timelineTitle: "关联处理任务" });
await clickButton(client, "更多操作");
await waitForText(client, "复制处理链接");
await clickButton(client, "查看 SLA 记录");
await waitForText(client, "已打开 IH-2026-0148 的 SLA 处理记录");
await waitForExpression(client, `JSON.parse(localStorage.getItem(${JSON.stringify(handlerActionKey)}) || "{}").slaViews?.[0]?.issueCode === "IH-2026-0148"`);
const handlerSlaView = await evaluateJson(client, `JSON.parse(localStorage.getItem(${JSON.stringify(handlerActionKey)}) || "{}").slaViews?.[0] || null`);
assert(handlerSlaView?.issueCode === "IH-2026-0148", `Handler SLA view record code mismatch. Got ${handlerSlaView?.issueCode}`);
assert(Number.isFinite(handlerSlaView?.slaRemainingMinutes), "Handler SLA view record did not persist SLA remaining minutes.");
assert(handlerSlaView.eventCount > 0, "Handler SLA view record did not persist related timeline count.");
await clickButton(client, "复制处理链接");
await waitForText(client, "已复制");
await waitForExpression(client, `JSON.parse(localStorage.getItem(${JSON.stringify(handlerActionKey)}) || "{}").copiedLinks?.[0]?.issueCode === "IH-2026-0148"`);
const copiedHandlerLink = await evaluateJson(client, `JSON.parse(localStorage.getItem(${JSON.stringify(handlerActionKey)}) || "{}").copiedLinks?.[0] || null`);
assert(copiedHandlerLink?.issueCode === "IH-2026-0148", `Handler copied link code mismatch. Got ${copiedHandlerLink?.issueCode}`);
assert(copiedHandlerLink.href?.includes("#/handler-detail?issue=issue-001"), "Handler copied link href was not persisted.");
await clickButton(client, "催办协作人");
await waitForIssueState(client, "issue-001", { status: "waiting_collaborator", timelineTitle: "催办协作人" });
await clickButton(client, "导出处理记录");
await waitForText(client, "已导出 IH-2026-0148");
const processExport = await evaluateJson(client, `JSON.parse(localStorage.getItem(${JSON.stringify(issueProcessExportKey)}) || "{}")`);
assert(processExport.fileName?.startsWith("issue-hub-process-IH-2026-0148-"), "Issue process export file name was not persisted.");
assert(processExport.issueCode === "IH-2026-0148", `Issue process export code mismatch. Got ${processExport.issueCode}`);
assert(Number.isFinite(processExport.rowCount) && processExport.rowCount > 0, "Issue process export row count was not persisted.");
await capture(client, "handler-comment-task-linked.png");
await clickButton(client, "展开全部");
await waitForText(client, "处理建议");
await clickButton(client, "查看完整分析报告");
await waitForText(client, "完整分析报告");
await clickButton(client, "全部下载");
await waitForText(client, "已记录下载");
const handlerSeedDownload = await evaluateJson(
client,
`(() => JSON.parse(localStorage.getItem(${JSON.stringify(attachmentDownloadKey)}) || "[]")[0] || null)()`
);
assert(handlerSeedDownload?.source === "handler", `Handler seed download record source mismatch. Got ${handlerSeedDownload?.source}`);
assert(handlerSeedDownload?.issueId === "issue-001", "Handler seed download record did not target issue-001.");
assert(handlerSeedDownload?.count === 2, `Handler seed download record count mismatch. Got ${handlerSeedDownload?.count}`);
await capture(client, "handler-detail-expanded-report.png");
await clickButton(client, "转派");
await waitForIssueState(client, "issue-001", { status: "dispatch_pending", timelineTitle: "转派" });
await clickButton(client, "提交修复并送测");
await waitForIssueState(client, "issue-001", { status: "ready_for_test", timelineTitle: "提交修复并送测" });
await capture(client, "handler-resolved.png");
await navigate(client, `${appOrigin}/#/submitter-tracking`);
await waitForText(client, "我提交的问题");
await setSearch(client, "");
await clickButton(client, "5 条/页");
await waitForText(client, "提交人追踪每页显示 5 条");
const submitterFirstPageFirstIssue = await evaluateJson(
client,
`document.querySelector(".tracking-issue-list > button strong")?.textContent?.trim() || ""`
);
await clickButtonByAriaLabel(client, "提交人追踪下一页");
await waitForExpression(
client,
`document.querySelector(".tracking-page-numbers button.active")?.textContent?.trim() === "2"`,
5_000,
"Submitter tracking pagination did not activate page 2."
);
await waitForText(client, "显示 6-");
const submitterSecondPageFirstIssue = await evaluateJson(
client,
`document.querySelector(".tracking-issue-list > button strong")?.textContent?.trim() || ""`
);
assert(submitterFirstPageFirstIssue && submitterSecondPageFirstIssue && submitterFirstPageFirstIssue !== submitterSecondPageFirstIssue, "Submitter tracking pagination did not change visible issue rows.");
await clickButton(client, "20 条/页");
await waitForText(client, "提交人追踪每页显示 20 条");
await waitForExpression(
client,
`document.querySelector(".tracking-page-numbers button.active")?.textContent?.trim() === "1" && Array.from(document.querySelectorAll(".tracking-page-size button.active")).some((button) => button.textContent?.trim() === "20 条/页")`,
5_000,
"Submitter tracking page size did not switch to 20 and return to page 1."
);
await waitForText(client, "显示 1-");
await clickButton(client, "5 条/页");
await waitForText(client, "提交人追踪每页显示 5 条");
await clickButtonByAriaLabel(client, "提交人追踪下一页");
await waitForExpression(
client,
`document.querySelector(".tracking-page-numbers button.active")?.textContent?.trim() === "2"`,
5_000,
"Submitter tracking pagination did not return to page 2 before reset checks."
);
await clickButton(client, "按创建时间");
await waitForExpression(
client,
`document.querySelector(".tracking-page-numbers button.active")?.textContent?.trim() === "1"`,
5_000,
"Submitter tracking sort did not reset pagination to page 1."
);
await setSearch(client, "订单支付成功");
await waitForText(client, "订单支付成功但状态未更新");
await clickButton(client, "复制记录");
await waitForText(client, "已复制 IH-2026-0148 的提交记录");
await clickButton(client, "按创建时间");
await waitForAnyText(client, ["提交记录已按创建时间旧到新排序", "提交记录已按创建时间新到旧排序"]);
await clickButtonByAriaLabel(client, "筛选提交记录");
await waitForText(client, "问题来源");
await clickButton(client, "仅 SLA 风险");
await waitForText(client, "已筛选提交记录:仅 SLA 风险");
await clickButton(client, "查看资料");
await waitForText(client, "技能标签");
await clickButton(client, "收起资料");
await clickButton(client, "查看全部回复");
await waitForText(client, "全部回复");
await clickButton(client, "收起全部回复");
await clickButton(client, "展开");
await waitForText(client, "完整描述");
await clickButton(client, "全部下载");
await waitForText(client, "已记录下载");
const submitterSeedDownload = await evaluateJson(
client,
`(() => JSON.parse(localStorage.getItem(${JSON.stringify(attachmentDownloadKey)}) || "[]")[0] || null)()`
);
assert(submitterSeedDownload?.source === "submitter", `Submitter seed download record source mismatch. Got ${submitterSeedDownload?.source}`);
assert(submitterSeedDownload?.issueId === "issue-001", "Submitter seed download record did not target issue-001.");
assert(submitterSeedDownload?.count === 2, `Submitter seed download record count mismatch. Got ${submitterSeedDownload?.count}`);
await capture(client, "submitter-detail-expanded-download.png");
await clickButton(client, "要求返工");
await waitForIssueState(client, "issue-001", { status: "reopened", timelineTitle: "要求返工" });
await capture(client, "submitter-reopened.png");
await clickButtonByAriaLabel(client, "筛选提交记录");
await waitForText(client, "SLA 状态");
await clickButton(client, "全部 SLA");
await waitForText(client, "已筛选提交记录:全部 SLA");
await setSearch(client, "报表导出数据量过大失败");
await waitForText(client, "报表导出数据量过大失败");
await clickButton(client, "确认已更新并关闭");
await waitForIssueState(client, "issue-006", { status: "closed", timelineTitle: "验收通过" });
await capture(client, "submitter-approved.png");
}
async function runEscalationSmoke(client) {
await navigate(client, `${appOrigin}/#/escalation-center`);
await waitForText(client, "提醒升级");
await setSearch(client, "");
const before = await getStoredState(client);
const beforeUnhandled = before.escalations?.filter((event) => !event.handled).length || 0;
await clickButton(client, "扫描 SLA");
await waitForText(client, "SLA 提醒");
await waitForText(client, "每 25 分钟允许重复提醒一次");
await waitForText(client, "渠道:站内、桌面");
await waitForStoredState(client, (state) => {
const unhandled = state.escalations?.filter((event) => !event.handled).length || 0;
return unhandled > beforeUnhandled
&& state.escalations?.some((event) => event.channels?.includes("in_app") && event.deliveryStatus === "desktop_ready")
&& state.timeline?.some((event) => event.title === "自动 SLA 扫描" && event.description?.includes("渠道:站内、桌面"));
});
await capture(client, "escalation-sla-scanned.png");
const scanned = await getStoredState(client);
assert(scanned.escalations?.some((event) => !event.handled), "No pending escalation event found after SLA scan.");
await clickButton(client, "立即催办");
await waitForText(client, "已立即催办");
await clickButton(client, "升级部门负责人");
await waitForText(client, "已升级");
await clickButton(client, "转中心复核");
await waitForText(client, "已将");
const beforeMark = await getStoredState(client);
const pendingIdBeforeMark = beforeMark.escalations?.find((event) => !event.handled)?.id;
assert(pendingIdBeforeMark, "No pending escalation event found before marking handled.");
await clickButton(client, "标记处理");
await waitForText(client, "提醒已标记处理");
await waitForStoredState(client, (state) => state.escalations?.find((event) => event.id === pendingIdBeforeMark)?.handled === true);
await capture(client, "escalation-marked-handled.png");
}
async function runMobileSubmitSmoke(client) {
await navigate(client, `${appOrigin}/#/mobile-submit`);
await waitForText(client, "移动端提交 / 补充");
await clickButton(client, "保存草稿");
await waitForText(client, "草稿已保存,可在下次打开提交页继续编辑。");
await waitForStoredState(client, (state) => state.issueDraft?.title === "门店日报导出缺少库存损耗字段");
await clickButtonByAriaLabel(client, "移动端提交附件上传");
await waitForText(client, "已选择 1 个附件");
await waitForText(client, "移动端已选择 1 个问题附件");
await clickButtonByAriaLabel(client, "移动端影响范围-多部门");
await setControlByAriaLabel(client, "移动端问题来源", "企业微信");
await setControlByAriaLabel(client, "移动端所属系统", "库存/履约");
await setControlByAriaLabel(client, "移动端期望解决时间", "6");
await capture(client, "mobile-submit-attachment-selected.png");
await clickButton(client, "提交到中心");
await waitForLocationHash(client, "#/submitter-tracking");
await waitForStoredState(
client,
(state) =>
state.issues?.[0]?.source === "企业微信" &&
state.issues?.[0]?.category === "库存/履约" &&
state.issues?.[0]?.impactScope === "多部门" &&
state.issues?.[0]?.slaMinutes === 360 &&
state.issues?.[0]?.slaRemainingMinutes === 360 &&
state.timeline?.[0]?.title === "提交问题"
);
const mobilePersisted = await evaluateJson(
client,
`(() => {
const data = JSON.parse(localStorage.getItem(${JSON.stringify(storageKey)}) || "{}");
const issue = data.issues?.[0] || {};
return {
source: issue.source,
category: issue.category,
impactScope: issue.impactScope,
slaMinutes: issue.slaMinutes,
slaRemainingMinutes: issue.slaRemainingMinutes
};
})()`
);
assert(mobilePersisted.source === "企业微信", `Mobile submitted issue source did not persist selected value. Got ${mobilePersisted.source}`);
assert(mobilePersisted.category === "库存/履约", `Mobile submitted issue category did not persist selected value. Got ${mobilePersisted.category}`);
assert(mobilePersisted.impactScope === "多部门", `Mobile submitted issue impact scope did not persist selected value. Got ${mobilePersisted.impactScope}`);
assert(mobilePersisted.slaMinutes === 360 && mobilePersisted.slaRemainingMinutes === 360, `Mobile submitted issue expected hours did not persist to SLA minutes. Got ${mobilePersisted.slaMinutes}/${mobilePersisted.slaRemainingMinutes}`);
await capture(client, "mobile-submitted-tracking.png");
await navigate(client, `${appOrigin}/#/mobile-submit`);
await waitForText(client, "移动端提交 / 补充");
await clickButton(client, "去补充");
await waitForTextareaValue(client, "补充涉及的订单号或数据 ID:");
await waitForText(client, "已定位需补充信息:涉及的订单号或数据 ID");
await clickButton(client, "联系处理人");
await waitForText(client, "已发起与处理人的会话");
await waitForStoredState(client, (state) => state.timeline?.some((event) => event.title === "提交人联系处理人"));
await clickButton(client, "催办");
await waitForText(client, "已提醒处理团队继续处理");
await waitForStoredState(client, (state) => state.timeline?.some((event) => event.title === "提交人催办"));
await clickButtonByAriaLabel(client, "移动端补充附件上传");
await waitForText(client, "已选择 1 个补充附件");
await waitForText(client, "移动端已选择 1 个补充附件");
await capture(client, "mobile-supplement-missing-upload.png");
await clickButtonByAriaLabel(client, "移动端提交补充信息");
const selected = await getStoredState(client);
const selectedIssueId = selected.selectedIssueId;
await waitForIssueState(client, selectedIssueId, { status: "dispatch_pending", timelineTitle: "提交人补充信息" });
const supplementedIssue = await getStoredState(client);
const supplementTarget = supplementedIssue.issues.find((issue) => issue.id === selectedIssueId);
const supplementEvent = supplementedIssue.timeline.find((event) => event.issueId === selectedIssueId && event.title === "提交人补充信息");
assert(supplementEvent?.description?.includes("补充涉及的订单号或数据 ID:"), "Mobile supplement timeline did not include submitted text.");
assert(supplementEvent?.description?.includes("补充附件:订单截图.zip"), "Mobile supplement timeline did not include submitted attachment name.");
assert(supplementTarget?.attachments?.some((attachment) => attachment.name === "订单截图.zip"), "Mobile supplement attachment was not persisted on the issue.");
await capture(client, "mobile-supplement-submitted.png");
}
async function runMobileNarrowSmoke(client) {
await setViewport(client, 390, 920, true);
await navigate(client, `${appOrigin}/#/mobile-submit`);
await waitForText(client, "提交到中心");
await dismissToast(client);
await capture(client, "mobile-narrow-submit.png");
await clickButton(client, "提交到中心");
await waitForLocationHash(client, "#/submitter-tracking");
await waitForStoredState(client, (state) => state.issues?.[0]?.source === "移动端" && state.timeline?.[0]?.title === "提交问题");
await capture(client, "mobile-narrow-submitted.png");
await navigate(client, `${appOrigin}/#/mobile-submit`);
await waitForText(client, "补充信息");
await clickButton(client, "去补充");
await waitForTextareaValue(client, "补充涉及的订单号或数据 ID:");
await capture(client, "mobile-narrow-supplement.png");
await clickButtonByAriaLabel(client, "移动端提交补充信息");
const selected = await getStoredState(client);
await waitForIssueState(client, selected.selectedIssueId, { status: "dispatch_pending", timelineTitle: "提交人补充信息" });
await setViewport(client, 1440, 940, false);
}
async function runKnowledgeAndRuleSmoke(client) {
await navigate(client, `${appOrigin}/#/knowledge-review`);
await waitForText(client, "知识复盘");
await clickButton(client, "复盘设置");
await waitForText(client, "关闭问题自动进入复盘池");
await setKnowledgeReviewSettings(client, { autoGenerateDraft: false, similarityThreshold: "90", defaultOwnerId: "u-chen" });
await clickButton(client, "保存设置");
await waitForText(client, "知识复盘设置已保存");
const knowledgeReviewSettings = await evaluateJson(client, `JSON.parse(localStorage.getItem(${JSON.stringify(knowledgeReviewSettingsKey)}) || "{}")`);
assert(knowledgeReviewSettings.autoGenerateDraft === false, "Knowledge review autoGenerateDraft setting was not persisted.");
assert(knowledgeReviewSettings.similarityThreshold === 90, "Knowledge review similarity threshold was not persisted.");
assert(knowledgeReviewSettings.defaultOwnerId === "u-chen", "Knowledge review owner was not persisted.");
await clickButton(client, "复盘设置");
await waitForExpression(client, `document.querySelector(".knowledge-settings-panel select")?.value === "90"`);
await clickButton(client, "关闭");
await capture(client, "knowledge-settings-saved.png");
await clickButton(client, "处理部门:全部");
await waitForText(client, "研发部");
await clickButton(client, "研发部");
await waitForText(client, "已筛选知识复盘处理部门:研发部");
await waitForText(client, "处理部门:研发部");
await clickButton(client, "5 条/页");
await waitForText(client, "知识复盘每页显示 5 条");
const knowledgeFirstPageIssue = await evaluateJson(
client,
`document.querySelector(".knowledge-table-row .knowledge-issue-cell strong")?.textContent?.trim() || ""`
);
await clickButtonByAriaLabel(client, "知识复盘下一页");
await waitForExpression(
client,
`document.querySelector(".knowledge-table-footer > div:first-of-type button.active")?.textContent?.trim() === "2"`,
5_000,
"Knowledge pagination did not activate page 2."
);
await waitForText(client, "共 62 条");
const knowledgeSecondPageIssue = await evaluateJson(
client,
`document.querySelector(".knowledge-table-row .knowledge-issue-cell strong")?.textContent?.trim() || ""`
);
assert(knowledgeFirstPageIssue && knowledgeSecondPageIssue && knowledgeFirstPageIssue !== knowledgeSecondPageIssue, "Knowledge pagination did not change visible issue rows.");
await clickButton(client, "20 条/页");
await waitForText(client, "知识复盘每页显示 20 条");
await waitForExpression(
client,
`document.querySelector(".knowledge-table-footer > div:first-of-type button.active")?.textContent?.trim() === "1" && Array.from(document.querySelectorAll(".knowledge-table-footer > div:last-child button.active")).some((button) => button.textContent?.trim() === "20 条/页")`,
5_000,
"Knowledge page size did not switch to 20 and return to page 1."
);
await waitForText(client, "共 62 条");
await setSearch(client, "报表导出数据量过大失败");
await waitForText(client, "报表导出数据量过大失败");
await clickKnowledgeIssue(client, "报表导出数据量过大失败");
await waitForStoredState(client, (state) => state.selectedIssueId === "issue-006");
await setSearch(client, "客户信息导入后字段映射失败");
await waitForText(client, "客户信息导入后字段映射失败");
await clickKnowledgeIssue(client, "客户信息导入后字段映射失败");
await waitForStoredState(client, (state) => state.selectedIssueId === "issue-007");
await waitForExpression(
client,
`document.querySelector(".knowledge-table-row.selected")?.textContent.includes("客户信息导入后字段映射失败")`,
5_000,
"Knowledge selected row did not switch to issue-007."
);
await capture(client, "knowledge-selected-other-issue.png");
await setSearch(client, "报表导出数据量过大失败");
await waitForText(client, "报表导出数据量过大失败");
await clickKnowledgeIssue(client, "报表导出数据量过大失败");
await waitForStoredState(client, (state) => state.selectedIssueId === "issue-006");
await clickButton(client, "生成知识条目");
await waitForStoredState(client, (state) => state.knowledgeDrafts?.some((draft) => draft.issueId === "issue-006") && state.timeline?.some((event) => event.issueId === "issue-006" && event.title === "生成知识条目草稿"));
await capture(client, "knowledge-draft-generated.png");
await clickButtonByAriaLabel(client, "编辑问题原因");
await setTextareaByPlaceholder(client, "输入复盘内容", "报表导出任务缺少分片限流,导致大数据量导出排队超时。");
await clickButton(client, "保存摘要");
await waitForStoredState(client, (state) => state.knowledgeDrafts?.some((draft) => draft.issueId === "issue-006" && draft.rootCause?.includes("分片限流")) && state.timeline?.some((event) => event.issueId === "issue-006" && event.title === "编辑知识复盘摘要"));
await capture(client, "knowledge-summary-edited.png");
await clickButton(client, "合并到已有知识");
await waitForStoredState(client, (state) => state.knowledgeDrafts?.some((draft) => draft.issueId === "issue-006") && state.timeline?.some((event) => event.issueId === "issue-006" && event.title === "合并知识库"));
await capture(client, "knowledge-draft-merged.png");
await setSearch(client, "客户信息导入后字段映射失败");
await waitForText(client, "客户信息导入后字段映射失败");
await clickButton(client, "标记无需沉淀");
await waitForStoredState(client, (state) => state.knowledgeDecisions?.some((decision) => decision.issueId === "issue-007" && decision.decision === "no_draft") && state.timeline?.some((event) => event.issueId === "issue-007" && event.title === "标记无需沉淀"));
await clickButton(client, "无需沉淀");
await waitForText(client, "无需沉淀");
await capture(client, "knowledge-no-draft-marked.png");
await navigate(client, `${appOrigin}/#/rule-config`);
await waitForText(client, "规则配置");
await setSearch(client, "");
await setSelectValueByIndex(client, 0, "auto_full");
await waitForStoredState(client, (state) => state.rules?.[0]?.automationMode === "auto_full");
await clickButton(client, "添加条件");
await waitForStoredState(client, (state) => state.rules?.[0]?.condition?.startsWith("RULE_CONDITIONS_V1:") && state.rules?.[0]?.condition?.includes("新增条件"));
await setControlByAriaLabelAtIndex(client, "匹配值", 5, "VIP 客户");
await waitForStoredState(client, (state) => state.rules?.[0]?.condition?.includes("VIP 客户"));
await setControlByAriaLabelAtIndex(client, "权重", 5, "15");
await waitForStoredState(client, (state) => state.rules?.[0]?.condition?.includes("\"weight\":15"));
await clickButton(client, "添加条件");
await waitForStoredState(client, (state) => (state.rules?.[0]?.condition?.match(/新增条件/g) || []).length >= 2);
await clickButton(client, "添加分组条件");
await waitForStoredState(client, (state) => state.rules?.[0]?.condition?.includes("AND 分组") && state.rules?.[0]?.condition?.includes("\"group\":\"AND\""));
await clickButtonByAriaLabel(client, "删除影响范围");
await waitForStoredState(client, (state) => state.rules?.[0]?.condition?.startsWith("RULE_CONDITIONS_V1:") && !state.rules?.[0]?.condition?.includes("\"label\":\"影响范围\""));
await capture(client, "rule-config-condition-removed.png");
await clickButton(client, "保存规则");
await waitForStoredState(client, (state) => state.rules?.[0]?.draftSavedAt && state.ruleAuditEntries?.some((entry) => entry.ruleId === state.rules?.[0]?.id && entry.title === "保存规则草稿"));
await clickButton(client, "查看变更记录");
await waitForText(client, "规则变更记录");
await waitForText(client, "保存规则草稿");
await capture(client, "rule-config-audit-log.png");
await clickButton(client, "测试规则");
await waitForText(client, "测试完成");
await capture(client, "rule-config-tested.png");
await clickButton(client, "发布配置");
await waitForText(client, "已发布");
await capture(client, "rule-config-published.png");
}
async function runAnalyticsSmoke(client) {
await navigate(client, `${appOrigin}/#/analytics-board`);
await waitForText(client, "管理看板");
await clickButton(client, "近 30 日");
await waitForText(client, "已筛选管理看板范围:近 30 日");
await clickButton(client, "研发部");
await waitForText(client, "已筛选管理看板部门:研发部");
await clickButton(client, "仅风险");
await waitForText(client, "已筛选管理看板视图:仅风险");
await waitForText(client, "当前视图:近 30 日 / 研发部 / 仅风险");
await clickButtonByAriaLabel(client, "查看看板固定日期范围");
await waitForText(client, "日期范围已固定为 2025-05-31 至 2025-06-06");
await clickButtonByAriaLabel(client, "切换趋势范围:近 30 日");
await waitForText(client, "当前视图:近 90 日 / 研发部 / 仅风险");
await waitForExpression(client, `JSON.parse(localStorage.getItem(${JSON.stringify(analyticsViewStateKey)}) || "{}").range === "90d"`);
await clickButton(client, "查看更多");
await waitForText(client, "洞察详情");
await clickButton(client, "收起详情");
await clickButton(client, "导出报表");
await waitForText(client, "已导出");
const exportRecord = await evaluateJson(client, `JSON.parse(localStorage.getItem(${JSON.stringify(analyticsExportKey)}) || "{}")`);
assert(exportRecord.fileName?.startsWith("issue-hub-analytics-近 90 日-"), "Analytics export file name was not persisted.");
assert(Number.isFinite(exportRecord.rowCount) && exportRecord.rowCount >= 0, "Analytics export row count was not persisted.");
await clickButton(client, "查看全部部门");
await waitForText(client, "部门负载明细");
await waitForText(client, "IH-2026-0148");
await waitForExpression(client, `JSON.parse(localStorage.getItem(${JSON.stringify(analyticsDrilldownKey)}) || "[]")[0]?.type === "department"`);
await clickButton(client, "查看全部分类");
await waitForText(client, "重复问题归因");
await waitForExpression(client, `JSON.parse(localStorage.getItem(${JSON.stringify(analyticsDrilldownKey)}) || "[]")[0]?.type === "repeat"`);
await clickButton(client, "查看全部 SLA 风险");
await waitForText(client, "高风险 SLA 风险明细");
await waitForExpression(client, `JSON.parse(localStorage.getItem(${JSON.stringify(analyticsDrilldownKey)}) || "[]")[0]?.type === "sla"`);
await clickButtonInSelector(client, ".ai-insight-panel", "查看详情");
await waitForText(client, "AI 洞察详情:");
await waitForExpression(client, `JSON.parse(localStorage.getItem(${JSON.stringify(analyticsDrilldownKey)}) || "[]")[0]?.type === "insight"`);
const drilldownRecords = await evaluateJson(client, `JSON.parse(localStorage.getItem(${JSON.stringify(analyticsDrilldownKey)}) || "[]")`);
assert(drilldownRecords.length >= 4, `Analytics drilldown records were not persisted. Got ${drilldownRecords.length}`);
await clickButton(client, "自定义看板");
await waitForText(client, "看板组件");
await clickCheckboxByLabel(client, "SLA 风险表");
await clickButton(client, "保存布局");
await waitForText(client, "已保存 4 个看板组件");
await waitForTextMissing(client, "SLA 风险概览");
const savedWidgets = await evaluateJson(client, `JSON.parse(localStorage.getItem(${JSON.stringify(analyticsLayoutKey)}) || "[]")`);
assert(Array.isArray(savedWidgets) && savedWidgets.length === 4 && !savedWidgets.includes("sla"), "Analytics layout was not persisted after saving widgets.");
await capture(client, "analytics-custom-layout-saved.png");
await clickButton(client, "刷新看板");
await waitForText(client, "看板刷新记录");
await waitForExpression(client, `JSON.parse(localStorage.getItem(${JSON.stringify(analyticsDrilldownKey)}) || "[]")[0]?.type === "refresh"`);
const analyticsViewState = await evaluateJson(client, `JSON.parse(localStorage.getItem(${JSON.stringify(analyticsViewStateKey)}) || "{}")`);
assert(analyticsViewState.range === "90d" && analyticsViewState.departmentId === "dept-rd" && analyticsViewState.focus === "breached", "Analytics view state did not keep the active filters.");
assert(typeof analyticsViewState.lastRefreshedAt === "string" && analyticsViewState.lastRefreshedAt.includes("T"), "Analytics refresh timestamp was not persisted.");
await capture(client, "analytics-filtered.png");
}
async function startMockSyncServer() {
const remoteIssue = {
id: "issue-sync-remote",
code: "IH-2026-SYNC",
title: "同步服务回放的远端问题",
description: "由浏览器 smoke mock 同步服务返回,用于验证 push/pull 成功路径。",
source: "服务端同步",
category: "同步验证",
submitterId: "u-submit",
departmentId: "dept-rd",
status: "dispatch_pending",
priority: "P2",
severity: "medium",
slaMinutes: 480,
slaRemainingMinutes: 360,
impactScope: "中心端与客户端同步链路",
summary: "远端问题已合并到本地调度平台状态。",
tags: ["同步", "smoke"],
createdAt: "2026-06-07T00:00:00.000Z",
updatedAt: "2026-06-07T00:10:00.000Z"
};
const remoteTimeline = {
id: "tl-sync-remote",
issueId: remoteIssue.id,
type: "submit",
actorId: "sync-service",
title: "服务端同步写入",
description: "mock 同步服务返回的远端时间线事件。",
createdAt: "2026-06-07T00:11:00.000Z"
};
let lastPushBody;
const server = createServer(async (request, response) => {
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, content-type, x-issue-hub-role, x-issue-hub-permissions",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS"
};
if (request.method === "OPTIONS") {
response.writeHead(204, corsHeaders);
response.end();
return;
}
if (request.url === "/api/health") {
writeJson(response, 200, { status: "ok" }, corsHeaders);
return;
}
if (request.url === "/api/v1/sync/push" && request.method === "POST") {
lastPushBody = await readJsonBody(request);
writeJson(response, 200, { serverTime: "2026-06-07T00:12:00.000Z" }, corsHeaders);
return;
}
if (request.url === "/api/v1/sync/pull" && request.method === "POST") {
await readJsonBody(request);
writeJson(
response,
200,
{
serverTime: "2026-06-07T00:13:00.000Z",
issues: [remoteIssue],
timeline: [remoteTimeline],
triageResults: [],
escalations: [],
departments: [],
users: [],
rules: [],
knowledgeDrafts: [],
knowledgeDecisions: [],
lastPushIssueCount: lastPushBody?.issues?.length || 0
},
corsHeaders
);
return;
}
writeJson(response, 404, { error: "not_found" }, corsHeaders);
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve(undefined));
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("Mock sync server did not expose a TCP port.");
log(`Mock sync server at http://127.0.0.1:${address.port}.`);
return {
origin: `http://127.0.0.1:${address.port}`,
close: () => new Promise((resolve) => server.close(() => resolve(undefined)))
};
}
async function startMockAiServer() {
let lastRequestBody;
const server = createServer(async (request, response) => {
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS"
};
if (request.method === "OPTIONS") {
response.writeHead(204, corsHeaders);
response.end();
return;
}
if (request.url === "/chat/completions" && request.method === "POST") {
lastRequestBody = await readJsonBody(request);
writeJson(
response,
200,
{
id: "chatcmpl-smoke",
choices: [
{
message: {
role: "assistant",
content: JSON.stringify({
summary: "mock AI 识别为支付回调状态同步异常。",
suggestedDepartmentId: "dept-rd",
suggestedAssigneeId: "u-chen",
confidence: 94,
categorySuggestion: "订单/支付",
prioritySuggestion: "P1",
reasons: ["支付回调和订单状态机技能匹配", "陈远当前响应较快", "同类问题曾由研发订单组处理"],
similarIssueCodes: ["IH-2026-0093", "IH-2026-0021"],
missingInfo: ["补充支付回调 requestId"]
})
}
}
],
requestModel: lastRequestBody?.model
},
corsHeaders
);
return;
}
writeJson(response, 404, { error: "not_found" }, corsHeaders);
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve(undefined));
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("Mock AI server did not expose a TCP port.");
log(`Mock AI server at http://127.0.0.1:${address.port}.`);
return {
origin: `http://127.0.0.1:${address.port}`,
close: () => new Promise((resolve) => server.close(() => resolve(undefined)))
};
}
function readJsonBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
request.on("error", reject);
request.on("end", () => {
const text = Buffer.concat(chunks).toString("utf8");
if (!text) {
resolve({});
return;
}
try {
resolve(JSON.parse(text));
} catch (error) {
reject(error);
}
});
});
}
function writeJson(response, statusCode, payload, headers = {}) {
response.writeHead(statusCode, { ...headers, "Content-Type": "application/json" });
response.end(JSON.stringify(payload));
}
async function ensureDevServer() {
log(`Checking dev server at ${appOrigin}.`);
if (process.env.SMOKE_APP_ORIGIN && await httpOk(`${appOrigin}/#/center-dashboard`)) {
log("Reusing existing dev server.");
return undefined;
}
log("Starting static dist server.");
const server = await startStaticDistServer();
await waitForHttp(`${appOrigin}/#/center-dashboard`, 20_000);
return { kill: () => server.close() };
}
function startStaticDistServer() {
const distDir = path.resolve("dist");
const indexPath = path.join(distDir, "index.html");
if (!existsSync(indexPath)) {
throw new Error("dist/index.html is missing. Run npm run build before browser smoke.");
}
const server = createServer((request, response) => {
const requestUrl = new URL(request.url || "/", appOrigin);
const pathname = requestUrl.pathname === "/" ? "/index.html" : decodeURIComponent(requestUrl.pathname);
const candidatePath = path.resolve(distDir, `.${pathname}`);
const filePath = candidatePath.startsWith(distDir) && existsSync(candidatePath) ? candidatePath : indexPath;
try {
response.writeHead(200, { "Content-Type": mimeType(filePath) });
response.end(readFileSync(filePath));
} catch (error) {
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
response.end(error instanceof Error ? error.message : String(error));
}
});
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(Number(new URL(appOrigin).port), "127.0.0.1", () => {
server.off("error", reject);
resolve(server);
});
});
}
function mimeType(filePath) {
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
if (filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
if (filePath.endsWith(".svg")) return "image/svg+xml";
if (filePath.endsWith(".png")) return "image/png";
if (filePath.endsWith(".jpg") || filePath.endsWith(".jpeg")) return "image/jpeg";
if (filePath.endsWith(".webp")) return "image/webp";
return "application/octet-stream";
}
function launchChrome(userDataDir) {
const chromePath = resolveChromePath();
const chrome = spawn(chromePath, [...createChromeArgs(), `--user-data-dir=${userDataDir}`, "about:blank"], {
stdio: ["ignore", "pipe", "pipe"]
});
chrome.stderr.on("data", (data) => {
const text = String(data);
if (!text.includes("DevTools listening")) process.stderr.write(`[chrome] ${text}`);
});
return chrome;
}
function createChromeArgs() {
return [
"--headless=new",
"--disable-gpu",
"--hide-scrollbars",
"--disable-crash-reporter",
"--disable-breakpad",
"--no-first-run",
"--no-default-browser-check",
`--remote-debugging-port=${chromePort}`,
"--window-size=1440,940"
];
}
function resolveChromePath() {
const candidates = [
process.env.CHROME_BIN,
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/usr/bin/google-chrome",
"/usr/bin/chromium-browser",
"/usr/bin/chromium"
].filter(Boolean);
return candidates.find((candidate) => existsSync(candidate)) || "chrome";
}
async function createPageTarget(url) {
const endpoint = `http://127.0.0.1:${chromePort}/json/new?${encodeURIComponent(url)}`;
const response = await fetchWithTimeout(endpoint, { method: "PUT" }, 3_000);
if (!response.ok) throw new Error(`Failed to create Chrome target: ${response.status}`);
return response.json();
}
async function navigate(client, url) {
await client.send("Page.navigate", { url });
await waitForExpression(client, "document.readyState === 'complete' || document.readyState === 'interactive'");
}
async function setViewport(client, width, height, mobile) {
await client.send("Emulation.setDeviceMetricsOverride", {
width,
height,
deviceScaleFactor: 1,
mobile
});
}
async function setSearch(client, value) {
const ok = await evaluateJson(
client,
`(() => {
const input = document.querySelector('input[aria-label="搜索问题编号、标题或负责人"]');
if (!input) return false;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
setter.call(input, ${JSON.stringify(value)});
input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(value)} }));
return input.value === ${JSON.stringify(value)};
})()`
);
assert(ok, "Failed to set global search input.");
}
async function setTextareaByPlaceholder(client, placeholderText, value) {
const ok = await evaluateJson(
client,
`(() => {
const textarea = Array.from(document.querySelectorAll("textarea")).find((item) => item.placeholder.includes(${JSON.stringify(placeholderText)}));
if (!textarea) return false;
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value").set;
setter.call(textarea, ${JSON.stringify(value)});
textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(value)} }));
textarea.dispatchEvent(new Event("change", { bubbles: true }));
return textarea.value === ${JSON.stringify(value)};
})()`
);
assert(ok, `Textarea not found or not set: ${placeholderText}`);
}
async function setSubmitField(client, labelText, value) {
const ok = await evaluateJson(
client,
`(() => {
const field = Array.from(document.querySelectorAll(".submit-field")).find((item) => item.textContent.includes(${JSON.stringify(labelText)}));
const control = field?.querySelector("input, select");
if (!control) return false;
const prototype = control instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(prototype, "value").set;
setter.call(control, ${JSON.stringify(value)});
control.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(value)} }));
control.dispatchEvent(new Event("change", { bubbles: true }));
return control.value === ${JSON.stringify(value)};
})()`
);
assert(ok, `Submit field not found or not set: ${labelText}`);
}
async function setFirstTextareaValue(client, value) {
const ok = await evaluateJson(
client,
`(() => {
const control = document.querySelector(".submit-issue-form textarea");
if (!control) return false;
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value").set;
setter.call(control, ${JSON.stringify(value)});
control.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(value)} }));
control.dispatchEvent(new Event("change", { bubbles: true }));
return control.value === ${JSON.stringify(value)};
})()`
);
assert(ok, "Submit description textarea not found or not set.");
}
async function waitForSubmitFieldValue(client, labelText, expectedValue, timeout = 5_000) {
await waitForExpression(
client,
`(() => {
const field = Array.from(document.querySelectorAll(".submit-field")).find((item) => item.textContent.includes(${JSON.stringify(labelText)}));
const control = field?.querySelector("input, select");
return control?.value === ${JSON.stringify(expectedValue)};
})()`,
timeout,
`Submit field value did not match: ${labelText} = ${expectedValue}`
);
}
async function waitForTextareaValue(client, expectedText, timeout = 5_000) {
await waitForExpression(
client,
`Array.from(document.querySelectorAll("textarea")).some((item) => item.value.includes(${JSON.stringify(expectedText)}))`,
timeout,
`Textarea value not found: ${expectedText}`
);
}
async function clickButton(client, text) {
const ok = await evaluateJson(
client,
`(() => {
const button = Array.from(document.querySelectorAll("button")).find((item) => item.textContent.trim().includes(${JSON.stringify(text)}));
if (!button) return false;
button.click();
return true;
})()`
);
assert(ok, `Button not found: ${text}`);
}
async function clickButtonInSelector(client, selector, text) {
const ok = await evaluateJson(
client,
`(() => {
const container = document.querySelector(${JSON.stringify(selector)});
const button = Array.from(container?.querySelectorAll("button") || []).find((item) => item.textContent.trim().includes(${JSON.stringify(text)}));
if (!button) return false;
button.click();
return true;
})()`
);
assert(ok, `Button not found in ${selector}: ${text}`);
}
async function clickKnowledgeIssue(client, title) {
const ok = await evaluateJson(
client,
`(() => {
const row = Array.from(document.querySelectorAll(".knowledge-table-row")).find((item) => item.textContent.includes(${JSON.stringify(title)}));
if (!row) return false;
row.click();
return true;
})()`
);
assert(ok, `Knowledge issue row not found: ${title}`);
}
async function clickButtonByAriaLabel(client, label) {
const ok = await evaluateJson(
client,
`(() => {
const button = document.querySelector(${JSON.stringify(`button[aria-label="${label}"]`)});
if (!button) return false;
button.click();
return true;
})()`
);
assert(ok, `Button aria-label not found: ${label}`);
}
async function setControlByAriaLabel(client, label, value) {
const ok = await evaluateJson(
client,
`(() => {
const control = document.querySelector(${JSON.stringify(`[aria-label="${label}"]`)});
if (!control || !(control instanceof HTMLInputElement || control instanceof HTMLSelectElement || control instanceof HTMLTextAreaElement)) return false;
const prototype = control instanceof HTMLSelectElement
? HTMLSelectElement.prototype
: control instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(prototype, "value").set;
setter.call(control, ${JSON.stringify(value)});
control.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(value)} }));
control.dispatchEvent(new Event("change", { bubbles: true }));
return control.value === ${JSON.stringify(value)};
})()`
);
assert(ok, `Control aria-label not found or not set: ${label}`);
}
async function setControlByAriaLabelAtIndex(client, label, index, value) {
const ok = await evaluateJson(
client,
`(() => {
const controls = Array.from(document.querySelectorAll(${JSON.stringify(`[aria-label="${label}"]`)}));
const control = controls[${index}];
if (!control || !(control instanceof HTMLInputElement || control instanceof HTMLSelectElement || control instanceof HTMLTextAreaElement)) return false;
const prototype = control instanceof HTMLSelectElement
? HTMLSelectElement.prototype
: control instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(prototype, "value").set;
setter.call(control, ${JSON.stringify(value)});
control.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(value)} }));
control.dispatchEvent(new Event("change", { bubbles: true }));
return control.value === ${JSON.stringify(value)};
})()`
);
assert(ok, `Control aria-label not found or not set at index ${index}: ${label}`);
}
async function setSettingsField(client, labelText, value) {
const ok = await evaluateJson(
client,
`(() => {
const field = Array.from(document.querySelectorAll(".settings-field")).find((item) => item.textContent.includes(${JSON.stringify(labelText)}));
const control = field?.querySelector("input, select");
if (!control) return false;
const prototype = control instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(prototype, "value").set;
setter.call(control, ${JSON.stringify(value)});
control.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(value)} }));
control.dispatchEvent(new Event("change", { bubbles: true }));
return control.value === ${JSON.stringify(value)};
})()`
);
assert(ok, `Settings field not found or not set: ${labelText}`);
}
async function readSettingsField(client, labelText) {
const value = await evaluateJson(
client,
`(() => {
const field = Array.from(document.querySelectorAll(".settings-field")).find((item) => item.textContent.includes(${JSON.stringify(labelText)}));
const control = field?.querySelector("input, select");
return control?.value || "";
})()`
);
assert(value, `Settings field not found or empty: ${labelText}`);
return value;
}
async function toggleSwitch(client, text) {
const ok = await evaluateJson(
client,
`(() => {
const row = Array.from(document.querySelectorAll(".switch-row")).find((item) => item.textContent.includes(${JSON.stringify(text)}));
const checkbox = row?.querySelector('input[type="checkbox"]');
if (!checkbox) return false;
checkbox.click();
return checkbox.checked;
})()`
);
assert(ok, `Switch not found or not enabled: ${text}`);
}
async function togglePermission(client, roleName, permissionLabel, expectedChecked = true) {
const ok = await evaluateJson(
client,
`(() => {
const checkbox = document.querySelector(${JSON.stringify(`input[aria-label="${roleName}-${permissionLabel}"]`)});
if (!checkbox) return false;
checkbox.click();
return checkbox.checked === ${JSON.stringify(expectedChecked)};
})()`
);
assert(ok, `Permission checkbox not found or not set as expected: ${roleName}-${permissionLabel}`);
}
async function setKnowledgeReviewSettings(client, settings) {
const ok = await evaluateJson(
client,
`(() => {
const panel = document.querySelector(".knowledge-settings-panel");
if (!panel) return false;
const draftLabel = Array.from(panel.querySelectorAll("label")).find((item) => item.textContent.includes("AI 自动生成知识草稿"));
const draftCheckbox = draftLabel?.querySelector('input[type="checkbox"]');
const selects = Array.from(panel.querySelectorAll("select"));
if (!draftCheckbox || selects.length < 2) return false;
if (draftCheckbox.checked !== ${JSON.stringify(settings.autoGenerateDraft)}) {
draftCheckbox.click();
}
selects[0].value = ${JSON.stringify(settings.similarityThreshold)};
selects[0].dispatchEvent(new Event("change", { bubbles: true }));
selects[1].value = ${JSON.stringify(settings.defaultOwnerId)};
selects[1].dispatchEvent(new Event("change", { bubbles: true }));
return draftCheckbox.checked === ${JSON.stringify(settings.autoGenerateDraft)}
&& selects[0].value === ${JSON.stringify(settings.similarityThreshold)}
&& selects[1].value === ${JSON.stringify(settings.defaultOwnerId)};
})()`
);
assert(ok, "Knowledge review settings panel did not accept the requested values.");
}
async function clickCheckboxByLabel(client, labelText) {
const ok = await evaluateJson(
client,
`(() => {
const label = Array.from(document.querySelectorAll("label")).find((item) => item.textContent.includes(${JSON.stringify(labelText)}));
const checkbox = label?.querySelector('input[type="checkbox"]');
if (!checkbox) return false;
checkbox.click();
return true;
})()`
);
assert(ok, `Checkbox label not found: ${labelText}`);
}
async function assertCheckboxByLabel(client, labelText, expectedChecked) {
const checked = await evaluateJson(
client,
`(() => {
const label = Array.from(document.querySelectorAll("label")).find((item) => item.textContent.includes(${JSON.stringify(labelText)}));
const checkbox = label?.querySelector('input[type="checkbox"]');
return checkbox ? checkbox.checked : null;
})()`
);
assert(checked === expectedChecked, `Checkbox ${labelText} expected ${expectedChecked} but got ${checked}.`);
}
async function clickCheckboxInRegion(client, regionText) {
const ok = await evaluateJson(
client,
`(() => {
const containers = Array.from(document.querySelectorAll('[role="button"], button, article, .data-table-row'));
const region = containers.find((item) => item.textContent.includes(${JSON.stringify(regionText)}));
const checkbox = region?.querySelector('input[type="checkbox"]');
if (!checkbox) return false;
checkbox.click();
return true;
})()`
);
assert(ok, `Checkbox region not found: ${regionText}`);
}
async function dismissToast(client) {
await evaluateJson(
client,
`(() => {
const toast = document.querySelector(".toast");
if (toast) toast.click();
return true;
})()`
);
}
async function setSelectValueByIndex(client, index, value) {
const ok = await evaluateJson(
client,
`(() => {
const select = document.querySelectorAll("select")[${index}];
if (!select) return false;
select.value = ${JSON.stringify(value)};
select.dispatchEvent(new Event("change", { bubbles: true }));
return select.value === ${JSON.stringify(value)};
})()`
);
assert(ok, `Select ${index} did not accept value ${value}`);
}
async function setFirstSelectValue(client, selector, value) {
const ok = await evaluateJson(
client,
`(() => {
const select = document.querySelector(${JSON.stringify(selector)});
if (!select) return false;
select.value = ${JSON.stringify(value)};
select.dispatchEvent(new Event("change", { bubbles: true }));
return select.value === ${JSON.stringify(value)};
})()`
);
assert(ok, `Select control did not accept value ${value}: ${selector}`);
}
async function waitForText(client, text, timeout = 5_000) {
try {
await waitForExpression(client, `document.body && document.body.innerText.includes(${JSON.stringify(text)})`, timeout, `Text not found: ${text}`);
} catch (error) {
const snapshot = await evaluateJson(
client,
`(() => ({
href: location.href,
hash: location.hash,
readyState: document.readyState,
rootHtml: (document.getElementById("root")?.innerHTML || "").slice(0, 500),
bodyHtml: (document.body?.innerHTML || "").slice(0, 500),
body: (document.body?.innerText || "").slice(0, 1200),
resources: performance.getEntriesByType("resource").slice(-12).map((item) => item.name)
}))()`
).catch(() => null);
throw new Error(`${error.message}. Page snapshot: ${JSON.stringify(snapshot)}`);
}
}
async function waitForAnyText(client, texts, timeout = 5_000) {
const expression = `(() => {
const text = document.body?.innerText || "";
return ${JSON.stringify(texts)}.find((item) => text.includes(item)) || "";
})()`;
return waitForExpression(client, expression, timeout, `None of the texts were found: ${texts.join(", ")}`);
}
async function waitForTextMissing(client, text, timeout = 5_000) {
await waitForExpression(
client,
`document.body && !document.body.innerText.includes(${JSON.stringify(text)})`,
timeout,
`Text was still present: ${text}`
);
}
async function waitForLocationHash(client, hash, timeout = 5_000) {
await waitForExpression(client, `location.hash === ${JSON.stringify(hash)}`, timeout, `Hash did not become ${hash}`);
}
async function waitForStoredState(client, predicate, timeout = 5_000) {
const start = Date.now();
let lastState;
while (Date.now() - start < timeout) {
lastState = await evaluateJson(
client,
`(() => {
try { return JSON.parse(localStorage.getItem(${JSON.stringify(storageKey)}) || "{}"); }
catch { return {}; }
})()`
);
if (predicate(lastState)) return lastState;
await delay(120);
}
throw new Error(`Persisted state did not match expectation. Last state: ${JSON.stringify(lastState)}`);
}
async function getStoredState(client) {
return evaluateJson(
client,
`(() => {
try { return JSON.parse(localStorage.getItem(${JSON.stringify(storageKey)}) || "{}"); }
catch { return {}; }
})()`
);
}
async function waitForIssueState(client, issueId, expected, timeout = 5_000) {
return waitForStoredState(client, (state) => {
const issue = state.issues?.find((item) => item.id === issueId);
const hasTimeline = expected.timelineTitle
? state.timeline?.some((event) => event.issueId === issueId && event.title === expected.timelineTitle)
: true;
return Boolean(issue && issue.status === expected.status && hasTimeline);
}, timeout);
}
async function waitForExpression(client, expression, timeout = 5_000, message = `Expression timed out: ${expression}`) {
const start = Date.now();
while (Date.now() - start < timeout) {
const result = await evaluateJson(client, expression);
if (result) return result;
await delay(80);
}
throw new Error(message);
}
async function evaluate(client, expression) {
const result = await client.send("Runtime.evaluate", {
expression,
awaitPromise: true,
returnByValue: true
});
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.text || "Runtime evaluation failed.");
}
return result.result;
}
async function evaluateJson(client, expression) {
return (await evaluate(client, expression)).value;
}
async function capture(client, fileName) {
await captureToFile(client, path.join(artifactDir, fileName));
}
async function captureToFile(client, filePath) {
const screenshot = await client.send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false });
writeFileSync(filePath, Buffer.from(screenshot.data, "base64"));
}
async function httpOk(url) {
try {
const response = await fetchWithTimeout(url, { cache: "no-store" }, 1_000);
return response.ok;
} catch {
return false;
}
}
async function waitForHttp(url, timeout) {
const start = Date.now();
while (Date.now() - start < timeout) {
if (await httpOk(url)) return;
await delay(250);
}
throw new Error(`Timed out waiting for ${url}`);
}
async function waitForJson(url, timeout) {
const start = Date.now();
let lastError;
while (Date.now() - start < timeout) {
try {
const response = await fetchWithTimeout(url, {}, 1_000);
if (response.ok) return response.json();
} catch (error) {
lastError = error;
}
await delay(120);
}
throw new Error(`Timed out waiting for ${url}. ${lastError?.message || ""}`);
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function cleanupDirectory(directoryPath) {
for (let attempt = 0; attempt < 4; attempt += 1) {
try {
rmSync(directoryPath, { recursive: true, force: true });
return;
} catch (error) {
if (attempt === 3) {
console.warn(`[browser-smoke] Unable to remove temporary directory ${directoryPath}: ${error.message}`);
return;
}
await delay(180);
}
}
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
server.close(() => {
if (address && typeof address === "object") resolve(address.port);
else reject(new Error("Unable to allocate a free Chrome debugging port."));
});
});
server.on("error", reject);
});
}
async function fetchWithTimeout(url, options, timeout) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function log(message) {
console.log(`[browser-smoke] ${message}`);
}
class CdpClient {
static connect(wsUrl) {
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsUrl);
const client = new CdpClient(socket);
const timer = setTimeout(() => reject(new Error("Timed out connecting to Chrome DevTools WebSocket.")), 5_000);
socket.addEventListener("open", () => {
clearTimeout(timer);
resolve(client);
}, { once: true });
socket.addEventListener("error", (event) => {
clearTimeout(timer);
reject(new Error(`WebSocket error: ${event.message || "unknown"}`));
}, { once: true });
});
}
constructor(socket) {
this.socket = socket;
this.nextId = 1;
this.pending = new Map();
socket.addEventListener("message", (event) => this.handleMessage(event.data));
}
send(method, params = {}) {
const id = this.nextId++;
this.socket.send(JSON.stringify({ id, method, params }));
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
});
}
handleMessage(raw) {
const message = JSON.parse(raw);
if (message.method === "Runtime.exceptionThrown") {
const detail = message.params?.exceptionDetails;
const exception = detail?.exception?.description || detail?.text || "Unknown runtime exception";
console.error(`[browser-runtime] ${exception}`);
}
if (message.method === "Runtime.consoleAPICalled") {
const args = message.params?.args?.map((arg) => arg.value || arg.description).filter(Boolean).join(" ");
if (debugBrowserEvents && args) console.log(`[browser-console] ${args}`);
}
if (message.method === "Log.entryAdded") {
const entry = message.params?.entry;
if (debugBrowserEvents && entry?.text) console.error(`[browser-log] ${entry.level || "log"} ${entry.text}`);
}
if (message.method === "Network.loadingFailed") {
const failure = message.params;
if (debugBrowserEvents) console.error(`[browser-network] failed ${failure.type || "resource"} ${failure.errorText || ""}`);
}
if (message.method === "Network.responseReceived") {
const response = message.params?.response;
if (debugBrowserEvents && response?.status >= 400) console.error(`[browser-network] ${response.status} ${response.url}`);
}
if (!message.id) return;
const pending = this.pending.get(message.id);
if (!pending) return;
this.pending.delete(message.id);
if (message.error) {
pending.reject(new Error(`${message.error.message}: ${message.error.data || ""}`));
} else {
pending.resolve(message.result);
}
}
}