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); } } }