2145 lines
108 KiB
JavaScript
2145 lines
108 KiB
JavaScript
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, ";
|
||
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\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);
|
||
}
|
||
}
|
||
}
|