@szc-ft/mcp-szcd-client 0.27.4 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,8 @@
12
12
  */
13
13
 
14
14
  import { findChrome, getChromeInstallGuide } from "./chrome-finder.js";
15
+ import { AntAdapter } from "./ant-adapter.js";
16
+ import { createExpect, locator as expectLocator, validation as expectValidation, url as expectUrl, api as expectApi } from "./expect.js";
15
17
 
16
18
  export class BrowserEngine {
17
19
  constructor() {
@@ -20,6 +22,20 @@ export class BrowserEngine {
20
22
  this.mode = null; // 'connect' | 'launch'
21
23
  this.ownedBrowser = false; // launch 模式为 true,决定关闭时是否 kill
22
24
  this._activeFrame = null; // 当前活跃 iframe 上下文(微前端场景)
25
+ this._adapter = null; // 懒加载 AntAdapter
26
+ this._lastActWasDropdown = false; // 用于 act 入口自动 closeAllDropdowns
27
+ }
28
+
29
+ /**
30
+ * 获取 / 懒初始化 AntAdapter,支持 componentMeta 注入(来自 szcd MCP 私有组件元数据)
31
+ */
32
+ _getAdapter(componentMeta) {
33
+ if (!this._adapter) {
34
+ this._adapter = new AntAdapter({ componentMeta: componentMeta || {} });
35
+ } else if (componentMeta) {
36
+ this._adapter.componentMeta = { ...this._adapter.componentMeta, ...componentMeta };
37
+ }
38
+ return this._adapter;
23
39
  }
24
40
 
25
41
  /**
@@ -147,6 +163,7 @@ export class BrowserEngine {
147
163
  const result = await this._dispatchStep(step, context);
148
164
  if (step.type === "apiCapture" || step.type === "api-capture") {
149
165
  context.lastApiCapture = result;
166
+ this._lastApiCapture = result; // 同步到 engine,供 _expect 使用
150
167
  }
151
168
  return {
152
169
  ...result,
@@ -217,6 +234,17 @@ export class BrowserEngine {
217
234
  }
218
235
 
219
236
  async act(options = {}) {
237
+ // 上一次操作是 antSelect/antTreeSelect 等下拉控件 → 先关闭残留浮层(反馈第 3 条)
238
+ // 跨 frame 清理:覆盖主 frame + 子应用 iframe(微前端场景)
239
+ if (this._lastActWasDropdown && !options.skipCloseDropdowns) {
240
+ try {
241
+ await this._closeDropdownsAllFrames(this._activeFrame || this.page.mainFrame());
242
+ this._lastActWasDropdown = false;
243
+ } catch {
244
+ // closeAllDropdowns 失败不影响 act 继续
245
+ }
246
+ }
247
+
220
248
  const targetFrame = await this._resolveObserveFrame(options);
221
249
  if (options.menuPath) {
222
250
  return this._actMenuPath(targetFrame, options);
@@ -687,12 +715,15 @@ export class BrowserEngine {
687
715
  session.on("Network.requestWillBeSent", (event) => {
688
716
  const request = event.request || {};
689
717
  if (!matchesUrl(request.url) || !matchesMethod(request.method)) return;
718
+ // 为每条请求记录 frameUrl:关键的是框架帧的 URL,不是发起者 iframe 的 document
719
+ // event.frameId / event.loaderId 可关联到 Page.frameNavigated 事件
690
720
  requests.set(`${targetInfo.id}:${event.requestId}`, {
691
721
  id: event.requestId,
692
722
  targetId: targetInfo.id,
693
723
  targetType: targetInfo.type,
694
724
  targetUrl: targetInfo.url,
695
725
  frameId: event.frameId,
726
+ frameUrl: targetInfo.url, // 请求来源的 frame URL
696
727
  url: request.url,
697
728
  method: request.method,
698
729
  requestHeaders: normalizeHeaders(request.headers),
@@ -1126,11 +1157,309 @@ export class BrowserEngine {
1126
1157
  case "aiAssert": return this._aiAssert(step, context);
1127
1158
  case "runTask": return this.runTask(step, context);
1128
1159
  case "run-task": return this.runTask(step, context);
1160
+ // ===== Ant Design 适配层(6/22 反馈落地) =====
1161
+ case "antSelect": return this._antSelect(step);
1162
+ case "antTreeSelect":return this._antTreeSelect(step);
1163
+ case "antInput": return this._antInput(step);
1164
+ case "antRadio": return this._antRadio(step);
1165
+ case "antCheckbox": return this._antCheckbox(step);
1166
+ case "antSwitch": return this._antSwitch(step);
1167
+ case "antDatePicker":return this._antDatePicker(step);
1168
+ case "antCascader": return this._antCascader(step);
1169
+ case "antUpload": return this._antUpload(step);
1170
+ case "formFill": return this._formFill(step);
1171
+ case "closeAllDropdowns": return this._closeAllDropdowns(step);
1172
+ // ===== 语义断言层 =====
1173
+ case "expect": return this._expect(step);
1129
1174
  default:
1130
1175
  throw new Error(`Unknown step type: ${step.type}`);
1131
1176
  }
1132
1177
  }
1133
1178
 
1179
+ /**
1180
+ * 解析目标 frame,优先级(微前端场景兼容):
1181
+ * 1. step 显式传 frameContentContains / frameUrlContains / frameIndex → 用 _resolveObserveFrame
1182
+ * 2. 已有 _activeFrame(来自 setup.findFrame / 上一步 findFrame)→ 直接复用
1183
+ * 3. 否则回退到 mainFrame
1184
+ *
1185
+ * 同时做"frame 探活 + 自动召回":如果 _activeFrame 因子应用卸载而失效,
1186
+ * 自动清空并回退到 mainFrame,避免无限挂在已销毁的 frame 上。
1187
+ *
1188
+ * @returns {Promise<{frame, frameId, matchedBy, frameRecovered?}>} 含 frameRecovered 标志
1189
+ */
1190
+ async _getTargetFrame(step) {
1191
+ const hasExplicitFrame = step && (step.frameContentContains || step.frameUrlContains || step.frameIndex !== undefined);
1192
+
1193
+ // 路径 1:显式指定 frame → 总是用 _resolveObserveFrame
1194
+ if (hasExplicitFrame) {
1195
+ const resolved = await this._resolveObserveFrame(step);
1196
+ return { frame: resolved.frame, frameId: resolved.frameId, matchedBy: resolved.matchedBy, frameRecovered: false };
1197
+ }
1198
+
1199
+ // 路径 2:复用 _activeFrame(微前端场景的核心优化)
1200
+ if (this._activeFrame) {
1201
+ // 探活:确认 frame 还能 evaluate(未被子应用卸载)
1202
+ const alive = await this._activeFrame.evaluate(() => true).catch(() => false);
1203
+ if (alive) {
1204
+ return { frame: this._activeFrame, frameId: "active", matchedBy: "_activeFrame", frameRecovered: false };
1205
+ }
1206
+ // 失活:清空 _activeFrame,召回到 mainFrame
1207
+ this._activeFrame = null;
1208
+ return { frame: this.page.mainFrame(), frameId: "main", matchedBy: "_activeFrame failed, fallback to main", frameRecovered: true };
1209
+ }
1210
+
1211
+ // 路径 3:mainFrame
1212
+ const resolved = await this._resolveObserveFrame(step || {});
1213
+ return { frame: resolved.frame, frameId: resolved.frameId, matchedBy: resolved.matchedBy, frameRecovered: false };
1214
+ }
1215
+
1216
+ async _antSelect(step) {
1217
+ const { frame } = await this._getTargetFrame(step);
1218
+ const adapter = this._getAdapter(step.componentMeta);
1219
+ let result = await adapter.select(frame, step.field || step.label, step.option || step.value, {
1220
+ mode: step.mode,
1221
+ timeout: step.timeout,
1222
+ });
1223
+
1224
+ // 微前端跨 frame 浮层兜底:
1225
+ // 如果当前 frame 未找到浮层选项(getPopupContainer 指向主文档),尝试主 frame
1226
+ if (!result.acted && result.reason?.includes?.("not found in dropdown")) {
1227
+ const mainFrame = this.page.mainFrame();
1228
+ if (mainFrame !== frame) {
1229
+ const retry = await adapter.select(mainFrame, step.field || step.label, step.option || step.value, {
1230
+ mode: step.mode,
1231
+ timeout: step.timeout,
1232
+ });
1233
+ if (retry.acted) {
1234
+ result = retry;
1235
+ result._dropdownFrame = "main";
1236
+ }
1237
+ }
1238
+ }
1239
+
1240
+ this._lastActWasDropdown = true;
1241
+ return { type: "antSelect", passed: result.acted && result.verified !== false, ...result };
1242
+ }
1243
+
1244
+ async _antTreeSelect(step) {
1245
+ const { frame } = await this._getTargetFrame(step);
1246
+ const adapter = this._getAdapter(step.componentMeta);
1247
+ let result = await adapter.treeSelect(frame, step.field || step.label, step.path || step.value, {
1248
+ timeout: step.timeout,
1249
+ });
1250
+
1251
+ // 微前端跨 frame 浮层兜底:同上
1252
+ if (!result.acted && result.reason?.includes?.("node")) {
1253
+ const mainFrame = this.page.mainFrame();
1254
+ if (mainFrame !== frame) {
1255
+ const retry = await adapter.treeSelect(mainFrame, step.field || step.label, step.path || step.value, { timeout: step.timeout });
1256
+ if (retry.acted) {
1257
+ result = retry;
1258
+ result._dropdownFrame = "main";
1259
+ }
1260
+ }
1261
+ }
1262
+
1263
+ this._lastActWasDropdown = true;
1264
+ return { type: "antTreeSelect", passed: result.acted && result.verified !== false, ...result };
1265
+ }
1266
+
1267
+ async _antInput(step) {
1268
+ const { frame } = await this._getTargetFrame(step);
1269
+ const adapter = this._getAdapter(step.componentMeta);
1270
+ const target = step.placeholder
1271
+ ? { placeholder: step.placeholder }
1272
+ : step.selector
1273
+ ? { selector: step.selector }
1274
+ : { label: step.field || step.label };
1275
+ const result = await adapter.input(frame, target, step.value, { clear: step.clear });
1276
+ return { type: "antInput", passed: result.acted, ...result };
1277
+ }
1278
+
1279
+ async _antRadio(step) {
1280
+ const { frame } = await this._getTargetFrame(step);
1281
+ const adapter = this._getAdapter(step.componentMeta);
1282
+ const result = await adapter.radio(frame, step.field || step.label, step.option || step.value);
1283
+ return { type: "antRadio", passed: result.acted && result.verified, ...result };
1284
+ }
1285
+
1286
+ async _antCheckbox(step) {
1287
+ const { frame } = await this._getTargetFrame(step);
1288
+ const adapter = this._getAdapter(step.componentMeta);
1289
+ const result = await adapter.checkbox(frame, step.field || step.label, step.option, step.checked !== false);
1290
+ return { type: "antCheckbox", passed: result.acted && result.verified, ...result };
1291
+ }
1292
+
1293
+ async _antSwitch(step) {
1294
+ const { frame } = await this._getTargetFrame(step);
1295
+ const adapter = this._getAdapter(step.componentMeta);
1296
+ const result = await adapter.switch(frame, step.field || step.label, step.on !== false);
1297
+ return { type: "antSwitch", passed: result.acted && result.verified, ...result };
1298
+ }
1299
+
1300
+ async _antDatePicker(step) {
1301
+ const { frame } = await this._getTargetFrame(step);
1302
+ const adapter = this._getAdapter(step.componentMeta);
1303
+ const result = await adapter.datePicker(frame, step.field || step.label, step.value);
1304
+ this._lastActWasDropdown = true;
1305
+ return { type: "antDatePicker", passed: result.acted, ...result };
1306
+ }
1307
+
1308
+ async _antCascader(step) {
1309
+ const { frame } = await this._getTargetFrame(step);
1310
+ const adapter = this._getAdapter(step.componentMeta);
1311
+ const result = await adapter.cascader(frame, step.field || step.label, step.path || step.value, { timeout: step.timeout });
1312
+ this._lastActWasDropdown = true;
1313
+ return { type: "antCascader", passed: result.acted, ...result };
1314
+ }
1315
+
1316
+ async _antUpload(step) {
1317
+ const { frame } = await this._getTargetFrame(step);
1318
+ const adapter = this._getAdapter(step.componentMeta);
1319
+ const result = await adapter.upload(frame, step.field || step.label, step.filePath || step.value);
1320
+ return { type: "antUpload", passed: result.acted, ...result };
1321
+ }
1322
+
1323
+ async _formFill(step) {
1324
+ const { frame } = await this._getTargetFrame(step);
1325
+ const adapter = this._getAdapter(step.componentMeta);
1326
+ const result = await adapter.formFill(frame, step.fields || [], { stepDelay: step.stepDelay });
1327
+ const allOk = result.summary.failed === 0;
1328
+ // 校验:可选地立即跑表单校验
1329
+ if (step.validateAfter) {
1330
+ const exp = createExpect({ frame, page: this.page });
1331
+ const validation = await exp.validation().toPass({ timeout: step.validationTimeout ?? 2000 });
1332
+ return {
1333
+ type: "formFill",
1334
+ passed: allOk && validation.passed,
1335
+ ...result,
1336
+ validation,
1337
+ };
1338
+ }
1339
+ return { type: "formFill", passed: allOk, ...result };
1340
+ }
1341
+
1342
+ async _closeAllDropdowns(step) {
1343
+ const { frame } = await this._getTargetFrame(step || {});
1344
+ const adapter = this._getAdapter();
1345
+ // 微前端场景:同时清理主 frame 和所有子 frame 的浮层(修复 2 配套)
1346
+ const result = await this._closeDropdownsAllFrames(frame);
1347
+ this._lastActWasDropdown = false;
1348
+ return { type: "closeAllDropdowns", passed: true, ...result };
1349
+ }
1350
+
1351
+ /**
1352
+ * 修复 2:跨 frame 浮层检测 — Ant 浮层可能渲染在子应用 iframe 内或父应用 document.body
1353
+ * 同时清理当前 frame + 主 frame + 所有可访问子 frame 的浮层
1354
+ */
1355
+ async _closeDropdownsAllFrames(currentFrame) {
1356
+ const adapter = this._getAdapter();
1357
+ const mainFrame = this.page.mainFrame();
1358
+ const allFrames = this.page.frames();
1359
+ const closed = { perFrame: [], total: 0 };
1360
+
1361
+ // 优先清理当前 frame
1362
+ const framesToClose = new Set([currentFrame, mainFrame, ...allFrames]);
1363
+ for (const frame of framesToClose) {
1364
+ try {
1365
+ // 探活
1366
+ const alive = await frame.evaluate(() => true).catch(() => false);
1367
+ if (!alive) continue;
1368
+ const r = await adapter.closeAllDropdowns(frame);
1369
+ if (r?.closed) {
1370
+ closed.total += r.closed;
1371
+ closed.perFrame.push({ frameUrl: frame.url().slice(0, 100), closed: r.closed });
1372
+ }
1373
+ } catch {
1374
+ // 跨域 iframe 无法 evaluate,跳过
1375
+ }
1376
+ }
1377
+ return closed;
1378
+ }
1379
+
1380
+ /**
1381
+ * 通用 expect 断言:根据 step.assertion 路由到 expect.js 的不同断言方法
1382
+ *
1383
+ * 支持的 assertion:
1384
+ * - "locator.toBeVisible" / "toBeHidden" / "toBeEnabled" / "toHaveText" / "toHaveValue" / "toHaveCount" / "toHaveAttribute"
1385
+ * - "validation.toPass" / "validation.toHaveError"
1386
+ * - "url.toContain" / "url.toMatch"
1387
+ * - "api.toAllPass" / "api.toInclude" / "api.toHaveCount"
1388
+ */
1389
+ async _expect(step) {
1390
+ const assertion = step.assertion;
1391
+ const opts = { timeout: step.timeout, interval: step.interval };
1392
+
1393
+ if (!assertion) throw new Error("expect step requires assertion field");
1394
+
1395
+ // locator.* 系列
1396
+ if (assertion.startsWith("locator.") || assertion.startsWith("toBe") || assertion.startsWith("toHave")) {
1397
+ const { frame } = await this._getTargetFrame(step);
1398
+ const loc = expectLocator(frame, step.selector);
1399
+ const method = assertion.replace(/^locator\./, "");
1400
+ if (typeof loc[method] !== "function") {
1401
+ throw new Error(`Unknown locator assertion: ${method}`);
1402
+ }
1403
+ // toHaveText/toHaveValue/toHaveAttribute/toHaveCount 需要 expected 参数
1404
+ const expected = step.expected ?? step.value ?? step.text ?? step.count;
1405
+ const r = method === "toHaveAttribute"
1406
+ ? await loc.toHaveAttribute(step.name, expected, opts)
1407
+ : (expected !== undefined ? await loc[method](expected, opts) : await loc[method](opts));
1408
+ return { type: "expect", ...r };
1409
+ }
1410
+
1411
+ // validation.*
1412
+ if (assertion.startsWith("validation.")) {
1413
+ const { frame } = await this._getTargetFrame(step);
1414
+ const val = expectValidation(frame);
1415
+ const method = assertion.replace("validation.", "");
1416
+ const r = method === "toHaveError"
1417
+ ? await val.toHaveError(step.field || step.message, opts)
1418
+ : await val.toPass(opts);
1419
+ return { type: "expect", ...r };
1420
+ }
1421
+
1422
+ // url.*
1423
+ if (assertion.startsWith("url.")) {
1424
+ const u = expectUrl(this.page);
1425
+ const method = assertion.replace("url.", "");
1426
+ const r = method === "toMatch"
1427
+ ? await u.toMatch(step.regex || step.pattern, opts)
1428
+ : await u.toContain(step.value || step.contains, opts);
1429
+ return { type: "expect", ...r };
1430
+ }
1431
+
1432
+ // api.*
1433
+ if (assertion.startsWith("api.")) {
1434
+ const capture = step.captureResult || this._lastApiCapture;
1435
+ // 微前端:支持按 frame 范围过滤请求
1436
+ // scope: "all"(默认,全 page)/ "current-frame"(仅 _activeFrame 同源)
1437
+ let frameUrlContains = step.frameUrlContains;
1438
+ if (!frameUrlContains && step.scope === "current-frame" && this._activeFrame) {
1439
+ const url = this._activeFrame.url();
1440
+ // blob: URL(wujie 子应用)取冒号后面的部分;http(s): URL 取 origin
1441
+ if (url.startsWith("blob:")) {
1442
+ try { frameUrlContains = new URL(url.slice(5)).origin; } catch {}
1443
+ } else {
1444
+ try { frameUrlContains = new URL(url).origin; } catch {}
1445
+ }
1446
+ }
1447
+ const a = expectApi(capture, { scope: step.scope || "all", frameUrlContains });
1448
+ const method = assertion.replace("api.", "");
1449
+ if (typeof a[method] !== "function") {
1450
+ throw new Error(`Unknown api assertion: ${method}`);
1451
+ }
1452
+ const r = method === "toInclude"
1453
+ ? a.toInclude(step.urlPattern || step.pattern)
1454
+ : method === "toHaveCount"
1455
+ ? a.toHaveCount(step.expected ?? step.count)
1456
+ : a[method]();
1457
+ return { type: "expect", ...r };
1458
+ }
1459
+
1460
+ throw new Error(`Unknown expect assertion: ${assertion}`);
1461
+ }
1462
+
1134
1463
  async _navigate(step) {
1135
1464
  const start = Date.now();
1136
1465
  // 微前端/SSO 重定向链场景:用 domcontentloaded 避免 page 对象被销毁
@@ -1773,6 +2102,148 @@ export class BrowserEngine {
1773
2102
  this.browser = null;
1774
2103
  this.page = null;
1775
2104
  }
2105
+
2106
+ /**
2107
+ * 执行测试用例:支持 dataset 参数化、变量替换
2108
+ * @param {object} testCase - 用例配置
2109
+ * @param {object} context - 执行上下文
2110
+ * @returns {Promise<object>} 用例执行结果摘要
2111
+ */
2112
+ async executeTestCase(testCase, context = {}) {
2113
+ const startTime = Date.now();
2114
+ const results = [];
2115
+ const dataset = testCase.dataset || [{ __row: 0 }];
2116
+
2117
+ // setup 阶段:frame 预定位
2118
+ if (testCase.setup?.findFrame && !this._activeFrame) {
2119
+ const resolved = await this._resolveObserveFrame(testCase.setup.findFrame);
2120
+ this._activeFrame = resolved.frame;
2121
+ }
2122
+
2123
+ for (let dataRowIndex = 0; dataRowIndex < dataset.length; dataRowIndex++) {
2124
+ const dataRow = dataset[dataRowIndex];
2125
+ const rowStartTime = Date.now();
2126
+ const rowResults = [];
2127
+ let allPassed = true;
2128
+
2129
+ for (let stepIndex = 0; stepIndex < testCase.steps.length; stepIndex++) {
2130
+ const rawStep = testCase.steps[stepIndex];
2131
+ // 变量替换:{{key}} → dataRow[key]
2132
+ const step = this._substituteVariables(rawStep, dataRow);
2133
+
2134
+ // 微前端 frame 探活召回:每次 step 执行前确认 _activeFrame 仍然有效
2135
+ // 如果失效(子应用卸载/路由切换),尝试用 setup.findFrame 重新定位
2136
+ if (this._activeFrame) {
2137
+ const alive = await this._activeFrame.evaluate(() => true).catch(() => false);
2138
+ if (!alive) {
2139
+ this._activeFrame = null;
2140
+ if (testCase.setup?.findFrame) {
2141
+ const recovered = await this._resolveObserveFrame(testCase.setup.findFrame).catch(() => null);
2142
+ if (recovered?.frame) {
2143
+ this._activeFrame = recovered.frame;
2144
+ rowResults.push({
2145
+ type: "frameRecover",
2146
+ status: "PASS",
2147
+ duration: 0,
2148
+ stepIndex,
2149
+ dataRowIndex,
2150
+ matchedBy: recovered.matchedBy,
2151
+ reason: "auto-recovered from frame loss",
2152
+ });
2153
+ results.push(rowResults[rowResults.length - 1]);
2154
+ }
2155
+ }
2156
+ }
2157
+ }
2158
+
2159
+ const stepResult = await this.executeStep(step, context);
2160
+
2161
+ rowResults.push({
2162
+ ...stepResult,
2163
+ stepIndex,
2164
+ dataRowIndex,
2165
+ dataRow: Object.fromEntries(Object.entries(dataRow).map(([k]) => [k, String(dataRow[k]).slice(0, 80)])),
2166
+ });
2167
+ results.push(rowResults[rowResults.length - 1]);
2168
+
2169
+ if (stepResult.status === "FAIL") {
2170
+ allPassed = false;
2171
+ const recovery = testCase.recovery || {};
2172
+ const maxRetries = recovery.maxRetriesPerStep ?? 0;
2173
+ for (let retry = 0; retry < maxRetries; retry++) {
2174
+ const retryResult = await this.executeStep(step, context);
2175
+ rowResults.push({
2176
+ ...retryResult,
2177
+ stepIndex,
2178
+ dataRowIndex,
2179
+ retry: retry + 1,
2180
+ });
2181
+ results.push(rowResults[rowResults.length - 1]);
2182
+ if (retryResult.status === "PASS") {
2183
+ allPassed = true;
2184
+ break;
2185
+ }
2186
+ }
2187
+ if (!allPassed && step.continueOnFail === false) break;
2188
+ }
2189
+
2190
+ // frame 切换:如果 step 设置了新 frame,后续步骤默认走新 frame
2191
+ if (step.findFrame || step.frameContentContains || step.frameUrlContains) {
2192
+ const resolved = await this._resolveObserveFrame(step);
2193
+ this._activeFrame = resolved.frame;
2194
+ }
2195
+ }
2196
+
2197
+ rowResults.duration = Date.now() - rowStartTime;
2198
+ rowResults.passed = allPassed;
2199
+ }
2200
+
2201
+ const duration = Date.now() - startTime;
2202
+ const passed = results.filter((r) => r.status === "PASS").length;
2203
+ const failed = results.filter((r) => r.status === "FAIL").length;
2204
+
2205
+ return {
2206
+ title: testCase.title,
2207
+ datasetSize: dataset.length,
2208
+ totalSteps: results.length,
2209
+ passedSteps: passed,
2210
+ failedSteps: failed,
2211
+ passed: failed === 0,
2212
+ duration,
2213
+ results,
2214
+ summary: {
2215
+ perDataset: results.reduce((acc, r) => {
2216
+ const key = r.dataRowIndex;
2217
+ if (!acc[key]) acc[key] = { passed: 0, failed: 0, duration: 0 };
2218
+ if (r.status === "PASS") acc[key].passed++;
2219
+ else acc[key].failed++;
2220
+ acc[key].duration += r.duration;
2221
+ return acc;
2222
+ }, {}),
2223
+ },
2224
+ };
2225
+ }
2226
+
2227
+ /**
2228
+ * 递归替换对象中的 {{varName}} 变量
2229
+ */
2230
+ _substituteVariables(obj, dataRow) {
2231
+ if (typeof obj === "string") {
2232
+ return obj.replace(/\{\{([^}]+)\}\}/g, (_, key) => {
2233
+ const keys = key.split(".");
2234
+ let val = dataRow;
2235
+ for (const k of keys) val = val?.[k];
2236
+ return val !== undefined ? (typeof val === "string" ? val : String(val)) : "";
2237
+ });
2238
+ }
2239
+ if (Array.isArray(obj)) {
2240
+ return obj.map((item) => this._substituteVariables(item, dataRow));
2241
+ }
2242
+ if (obj && typeof obj === "object") {
2243
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, this._substituteVariables(v, dataRow)]));
2244
+ }
2245
+ return obj;
2246
+ }
1776
2247
  }
1777
2248
 
1778
2249
  /**