@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.
- package/lib/ant-adapter.js +716 -0
- package/lib/browser-engine.js +471 -0
- package/lib/expect.js +392 -0
- package/lib/test-plan.js +48 -0
- package/local-browser-executor.js +70 -1
- package/opencode-extension/skills/local-browser-test/SKILL.md +113 -0
- package/package.json +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +113 -0
- package/standard-skill/local-browser-test/SKILL.md +113 -0
package/lib/browser-engine.js
CHANGED
|
@@ -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
|
/**
|