@szc-ft/mcp-szcd-client 0.28.1 → 0.29.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/agents/szcd-component-expert.qoder.md +1 -0
- package/opencode-extension/skills/local-browser-test/SKILL.md +189 -53
- package/opencode-extension/skills/local-browser-test/lib/ant-adapter.js +735 -0
- package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +2427 -0
- package/opencode-extension/skills/local-browser-test/lib/chrome-finder.js +125 -0
- package/opencode-extension/skills/local-browser-test/lib/expect.js +392 -0
- package/opencode-extension/skills/local-browser-test/lib/shared-deps.js +137 -0
- package/opencode-extension/skills/local-browser-test/lib/test-plan.js +48 -0
- package/opencode-extension/skills/local-browser-test/lib/visual-compare.js +192 -0
- package/opencode-extension/skills/local-browser-test/local-browser-executor.js +735 -0
- package/opencode-extension/skills/local-browser-test/package.json +1 -0
- package/opencode-extension/skills/szcd-upload-zip/SKILL.md +122 -0
- package/package.json +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +189 -53
- package/qwen-extension/skills/local-browser-test/lib/ant-adapter.js +735 -0
- package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +2427 -0
- package/qwen-extension/skills/local-browser-test/lib/chrome-finder.js +125 -0
- package/qwen-extension/skills/local-browser-test/lib/expect.js +392 -0
- package/qwen-extension/skills/local-browser-test/lib/shared-deps.js +137 -0
- package/qwen-extension/skills/local-browser-test/lib/test-plan.js +48 -0
- package/qwen-extension/skills/local-browser-test/lib/visual-compare.js +192 -0
- package/qwen-extension/skills/local-browser-test/local-browser-executor.js +735 -0
- package/qwen-extension/skills/local-browser-test/package.json +1 -0
- package/qwen-extension/skills/szcd-upload-zip/SKILL.md +122 -0
- package/standard-skill/local-browser-test/SKILL.md +189 -53
- package/standard-skill/local-browser-test/lib/ant-adapter.js +735 -0
- package/standard-skill/local-browser-test/lib/browser-engine.js +2427 -0
- package/standard-skill/local-browser-test/lib/chrome-finder.js +125 -0
- package/standard-skill/local-browser-test/lib/expect.js +392 -0
- package/standard-skill/local-browser-test/lib/shared-deps.js +137 -0
- package/standard-skill/local-browser-test/lib/test-plan.js +48 -0
- package/standard-skill/local-browser-test/lib/visual-compare.js +192 -0
- package/standard-skill/local-browser-test/local-browser-executor.js +735 -0
- package/standard-skill/local-browser-test/package.json +1 -0
- package/standard-skill/szcd-upload-zip/SKILL.md +122 -0
|
@@ -0,0 +1,2427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器控制引擎 - 双模式(connect / launch)
|
|
3
|
+
*
|
|
4
|
+
* 模式 A: connect(优先)
|
|
5
|
+
* 连接用户已启动的调试 Chrome(--remote-debugging-port=9222)
|
|
6
|
+
* 天然继承登录态/Cookie/SSO/微前端环境
|
|
7
|
+
*
|
|
8
|
+
* 模式 B: launch(回退)
|
|
9
|
+
* 启动新的 headless Chrome,需要用户提供可访问的 URL
|
|
10
|
+
*
|
|
11
|
+
* 依赖(optionalDependencies):puppeteer-core
|
|
12
|
+
*/
|
|
13
|
+
|
|
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";
|
|
17
|
+
|
|
18
|
+
export class BrowserEngine {
|
|
19
|
+
constructor() {
|
|
20
|
+
this.browser = null;
|
|
21
|
+
this.page = null;
|
|
22
|
+
this.mode = null; // 'connect' | 'launch'
|
|
23
|
+
this.ownedBrowser = false; // launch 模式为 true,决定关闭时是否 kill
|
|
24
|
+
this._activeFrame = null; // 当前活跃 iframe 上下文(微前端场景)
|
|
25
|
+
this._adapter = null; // 懒加载 AntAdapter
|
|
26
|
+
this._lastActWasDropdown = false; // 用于 act 入口自动 closeAllDropdowns
|
|
27
|
+
this._wujieContext = null; // wujie 微前端模式 { type, frameCount, wujieApp? }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 获取 / 懒初始化 AntAdapter,支持 componentMeta 注入(来自 szcd MCP 私有组件元数据)
|
|
32
|
+
*/
|
|
33
|
+
_getAdapter(componentMeta) {
|
|
34
|
+
if (!this._adapter) {
|
|
35
|
+
this._adapter = new AntAdapter({ componentMeta: componentMeta || {} });
|
|
36
|
+
} else if (componentMeta) {
|
|
37
|
+
this._adapter.componentMeta = { ...this._adapter.componentMeta, ...componentMeta };
|
|
38
|
+
}
|
|
39
|
+
return this._adapter;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 初始化:自动选择 connect 或 launch 模式
|
|
44
|
+
* @param {object} options
|
|
45
|
+
* @param {string} [options.cdpUrl='http://localhost:9222'] - CDP 调试地址
|
|
46
|
+
* @param {string} [options.pageUrl] - 目标页面 URL(connect 时用于匹配标签页)
|
|
47
|
+
* @param {object} [options.viewport] - 视口尺寸 { width, height }
|
|
48
|
+
* @returns {Promise<object>} 初始化结果
|
|
49
|
+
*/
|
|
50
|
+
async init(options = {}) {
|
|
51
|
+
const cdpUrl = options.cdpUrl || "http://localhost:9222";
|
|
52
|
+
|
|
53
|
+
// 优先尝试 connect 模式
|
|
54
|
+
if (await this.isDebuggerAvailable(cdpUrl)) {
|
|
55
|
+
return this.connect(cdpUrl, options);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 回退到 launch 模式
|
|
59
|
+
return this.launch(options);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 检查调试端口是否可用
|
|
64
|
+
*/
|
|
65
|
+
async isDebuggerAvailable(cdpUrl) {
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(`${cdpUrl}/json/version`, {
|
|
68
|
+
signal: AbortSignal.timeout(2000),
|
|
69
|
+
});
|
|
70
|
+
return res.ok;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 检测 wujie 微前端模式(WebComponent / iframe)
|
|
78
|
+
* @returns {{ type: 'webcomponent'|'iframe'|'none', frameCount: number, wujieApp?: object }}
|
|
79
|
+
*/
|
|
80
|
+
async _detectWujieMode() {
|
|
81
|
+
const frames = this.page.frames();
|
|
82
|
+
const frameCount = frames.length;
|
|
83
|
+
const mainFrame = this.page.mainFrame();
|
|
84
|
+
|
|
85
|
+
// 检查主文档中是否有 <wujie-app> 自定义元素(WebComponent 模式标志)
|
|
86
|
+
const wujieInfo = await mainFrame.evaluate(() => {
|
|
87
|
+
const app = document.querySelector('wujie-app');
|
|
88
|
+
if (!app) return null;
|
|
89
|
+
const sr = app.shadowRoot;
|
|
90
|
+
return {
|
|
91
|
+
tagName: app.tagName,
|
|
92
|
+
className: app.className,
|
|
93
|
+
hasShadowRoot: !!sr,
|
|
94
|
+
shadowChildren: sr?.children?.length || 0,
|
|
95
|
+
shadowBodyText: sr?.querySelector('body')?.innerText?.substring(0, 80) || ''
|
|
96
|
+
};
|
|
97
|
+
}).catch(() => null);
|
|
98
|
+
|
|
99
|
+
if (wujieInfo) {
|
|
100
|
+
const ctx = { type: 'webcomponent', frameCount, wujieApp: wujieInfo };
|
|
101
|
+
process.stderr.write(`[wujie] WebComponent 模式: <${wujieInfo.tagName} class="${wujieInfo.className}"> shadowRoot=${wujieInfo.hasShadowRoot} children=${wujieInfo.shadowChildren} frames=${frameCount}\n`);
|
|
102
|
+
if (wujieInfo.shadowBodyText) {
|
|
103
|
+
process.stderr.write(`[wujie] shadow body preview: "${wujieInfo.shadowBodyText}"\n`);
|
|
104
|
+
}
|
|
105
|
+
// 列出所有 frame 供排障
|
|
106
|
+
for (let i = 0; i < frames.length; i++) {
|
|
107
|
+
process.stderr.write(`[wujie] frame[${i}]: ${frames[i].url().substring(0, 100)}\n`);
|
|
108
|
+
}
|
|
109
|
+
return ctx;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (frameCount > 1) {
|
|
113
|
+
process.stderr.write(`[wujie] iframe 模式: ${frameCount} frames\n`);
|
|
114
|
+
for (let i = 0; i < frames.length; i++) {
|
|
115
|
+
process.stderr.write(`[wujie] frame[${i}]: ${frames[i].url().substring(0, 100)}\n`);
|
|
116
|
+
}
|
|
117
|
+
return { type: 'iframe', frameCount };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
process.stderr.write(`[wujie] 未检测到微前端 (frames=${frameCount})\n`);
|
|
121
|
+
return { type: 'none', frameCount };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 安全获取页面列表(优先同步 targets() 避免 pages() 挂死 + 泄露 WebSocket)
|
|
126
|
+
*/
|
|
127
|
+
async _safeGetPages() {
|
|
128
|
+
// 1. 优先使用同步 targets(),不会挂死也不会泄露未完成的 promise
|
|
129
|
+
try {
|
|
130
|
+
const targets = this.browser.targets();
|
|
131
|
+
const pageTargets = targets.filter((t) => t.type() === "page");
|
|
132
|
+
if (pageTargets.length > 0) {
|
|
133
|
+
const pages = [];
|
|
134
|
+
for (const t of pageTargets) {
|
|
135
|
+
try {
|
|
136
|
+
const p = await Promise.race([
|
|
137
|
+
t.page(),
|
|
138
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("page() timeout")), 3000)),
|
|
139
|
+
]);
|
|
140
|
+
if (p) pages.push(p);
|
|
141
|
+
} catch {
|
|
142
|
+
// 跳过无法获取的 target
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (pages.length > 0) return pages;
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// targets() 失败
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2. 回退:CDP Target.getTargets
|
|
152
|
+
try {
|
|
153
|
+
const client = await this.browser.target().createCDPSession();
|
|
154
|
+
const { targetInfos } = await client.send("Target.getTargets");
|
|
155
|
+
const pageInfos = targetInfos.filter((t) => t.type === "page");
|
|
156
|
+
const pages = [];
|
|
157
|
+
for (const info of pageInfos) {
|
|
158
|
+
try {
|
|
159
|
+
const target = await Promise.race([
|
|
160
|
+
this.browser.waitForTarget((t) => t.url() === info.url, { timeout: 2000 }),
|
|
161
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("waitForTarget timeout")), 3000)),
|
|
162
|
+
]);
|
|
163
|
+
if (target) {
|
|
164
|
+
const p = await Promise.race([
|
|
165
|
+
target.page(),
|
|
166
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("page() timeout")), 3000)),
|
|
167
|
+
]);
|
|
168
|
+
if (p) pages.push(p);
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// skip
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (pages.length > 0) return pages;
|
|
175
|
+
} catch {
|
|
176
|
+
// CDP 回退也失败
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 3. 最后手段:只能尝试 pages()(带严格超时,用完立即 disconnect/reconnect 清理)
|
|
180
|
+
try {
|
|
181
|
+
const pages = await Promise.race([
|
|
182
|
+
this.browser.pages(),
|
|
183
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("pages() timeout")), 5000)),
|
|
184
|
+
]);
|
|
185
|
+
if (pages && pages.length > 0) return pages;
|
|
186
|
+
} catch {
|
|
187
|
+
// pages() 超时;注意此时 browser.pages() 的 WebSocket 可能泄露
|
|
188
|
+
// 清理:断开后重新连接
|
|
189
|
+
try {
|
|
190
|
+
const wsUrl = this.browser.wsEndpoint();
|
|
191
|
+
const puppeteer = await loadPuppeteer();
|
|
192
|
+
await this.browser.disconnect();
|
|
193
|
+
this.browser = await puppeteer.connect({ browserWSEndpoint: wsUrl, defaultViewport: null });
|
|
194
|
+
} catch {
|
|
195
|
+
// reconnect 失败,继续用当前 browser
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 4. 最后手段:newPage()
|
|
200
|
+
return [await this.browser.newPage()];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 模式 A:连接到用户已有的 Chrome
|
|
205
|
+
*/
|
|
206
|
+
async connect(cdpUrl, options = {}) {
|
|
207
|
+
const puppeteer = await loadPuppeteer();
|
|
208
|
+
this.browser = await puppeteer.connect({ browserURL: cdpUrl, defaultViewport: null });
|
|
209
|
+
this.mode = "connect";
|
|
210
|
+
this.ownedBrowser = false;
|
|
211
|
+
|
|
212
|
+
// 获取页面:优先使用指定 URL 匹配的标签页
|
|
213
|
+
const pages = await this._safeGetPages();
|
|
214
|
+
if (options.pageUrl) {
|
|
215
|
+
this.page = pages.find((p) => p.url().includes(options.pageUrl))
|
|
216
|
+
|| await this.browser.newPage();
|
|
217
|
+
} else {
|
|
218
|
+
// 选择第一个非 chrome:// 的页面
|
|
219
|
+
this.page = pages.find((p) => !p.url().startsWith("chrome://"))
|
|
220
|
+
|| pages[0];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (options.viewport && this.page) {
|
|
224
|
+
await this.page.setViewport(options.viewport);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 微前端模式检测
|
|
228
|
+
this._wujieContext = await this._detectWujieMode();
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
mode: "connect",
|
|
232
|
+
cdpUrl,
|
|
233
|
+
currentUrl: this.page?.url() || null,
|
|
234
|
+
totalPages: pages.length,
|
|
235
|
+
allPageUrls: pages.map((p) => p.url()),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 模式 B:启动新的 Chrome
|
|
241
|
+
*/
|
|
242
|
+
async launch(options = {}) {
|
|
243
|
+
const puppeteer = await loadPuppeteer();
|
|
244
|
+
const chrome = findChrome();
|
|
245
|
+
|
|
246
|
+
if (!chrome.path) {
|
|
247
|
+
const guide = getChromeInstallGuide();
|
|
248
|
+
const error = new Error(
|
|
249
|
+
`${guide.message}\n安装方式:\n${guide.installCommands.join("\n")}\n\n` +
|
|
250
|
+
`或使用 connect 模式:以 --remote-debugging-port=9222 启动 Chrome`
|
|
251
|
+
);
|
|
252
|
+
error.code = "CHROME_NOT_FOUND";
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
this.browser = await puppeteer.launch({
|
|
257
|
+
executablePath: chrome.path,
|
|
258
|
+
headless: true,
|
|
259
|
+
args: [
|
|
260
|
+
"--no-sandbox",
|
|
261
|
+
"--disable-setuid-sandbox",
|
|
262
|
+
"--disable-dev-shm-usage",
|
|
263
|
+
"--disable-gpu",
|
|
264
|
+
"--no-first-run",
|
|
265
|
+
"--no-default-browser-check",
|
|
266
|
+
],
|
|
267
|
+
});
|
|
268
|
+
this.page = await this.browser.newPage();
|
|
269
|
+
this.mode = "launch";
|
|
270
|
+
this.ownedBrowser = true;
|
|
271
|
+
|
|
272
|
+
if (options.viewport) {
|
|
273
|
+
await this.page.setViewport(options.viewport);
|
|
274
|
+
} else {
|
|
275
|
+
await this.page.setViewport({ width: 1440, height: 900 });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
mode: "launch",
|
|
280
|
+
browser: chrome.name,
|
|
281
|
+
chromePath: chrome.path,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 执行单个步骤
|
|
287
|
+
* @param {object} step - 步骤定义
|
|
288
|
+
* @param {object} context - 执行上下文 { outputDir }
|
|
289
|
+
* @returns {Promise<object>} 步骤执行结果
|
|
290
|
+
*/
|
|
291
|
+
async executeStep(step, context = {}) {
|
|
292
|
+
const startTime = Date.now();
|
|
293
|
+
try {
|
|
294
|
+
const result = await this._dispatchStep(step, context);
|
|
295
|
+
if (step.type === "apiCapture" || step.type === "api-capture") {
|
|
296
|
+
context.lastApiCapture = result;
|
|
297
|
+
this._lastApiCapture = result; // 同步到 engine,供 _expect 使用
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
...result,
|
|
301
|
+
step: step.type,
|
|
302
|
+
duration: Date.now() - startTime,
|
|
303
|
+
status: result?.passed === false ? "FAIL" : "PASS",
|
|
304
|
+
};
|
|
305
|
+
} catch (err) {
|
|
306
|
+
return {
|
|
307
|
+
step: step.type,
|
|
308
|
+
duration: Date.now() - startTime,
|
|
309
|
+
status: "FAIL",
|
|
310
|
+
error: err.message,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* 安全执行 page/frame evaluate,捕获重定向导致的 context destroyed 错误
|
|
317
|
+
*/
|
|
318
|
+
async safeEval(fn, ...args) {
|
|
319
|
+
const target = this._activeFrame || this.page;
|
|
320
|
+
try {
|
|
321
|
+
return await target.evaluate(fn, ...args);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
if (err.message.includes("Not attached") || err.message.includes("Execution context")) {
|
|
324
|
+
// 重定向后 page/frame 可能已失效,重新获取
|
|
325
|
+
this._activeFrame = null;
|
|
326
|
+
return await this.page.evaluate(fn, ...args);
|
|
327
|
+
}
|
|
328
|
+
throw err;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async observe(options = {}) {
|
|
333
|
+
const targetFrame = await this._resolveObserveFrame(options);
|
|
334
|
+
const selector = options.selector || "body";
|
|
335
|
+
const depth = Number.parseInt(options.depth ?? 5, 10);
|
|
336
|
+
const maxInteractiveElements = Number.parseInt(options.maxInteractiveElements ?? 200, 10);
|
|
337
|
+
const frames = await this._getFrameSummaries();
|
|
338
|
+
const tree = await this._buildDomTree(targetFrame.frame, selector, depth);
|
|
339
|
+
const interactiveElements = await this._collectInteractiveElements(
|
|
340
|
+
targetFrame.frame,
|
|
341
|
+
targetFrame.frameId,
|
|
342
|
+
maxInteractiveElements
|
|
343
|
+
);
|
|
344
|
+
const title = await this.page.title().catch(() => "");
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
type: "observe",
|
|
348
|
+
url: this.page.url(),
|
|
349
|
+
title,
|
|
350
|
+
timestamp: new Date().toISOString(),
|
|
351
|
+
mode: this.mode,
|
|
352
|
+
target: {
|
|
353
|
+
frameId: targetFrame.frameId,
|
|
354
|
+
frameUrl: targetFrame.frame.url(),
|
|
355
|
+
matchedBy: targetFrame.matchedBy,
|
|
356
|
+
},
|
|
357
|
+
wujieContext: this._wujieContext,
|
|
358
|
+
frames,
|
|
359
|
+
tree,
|
|
360
|
+
interactiveElements,
|
|
361
|
+
summary: {
|
|
362
|
+
frames: frames.length,
|
|
363
|
+
interactiveElements: interactiveElements.length,
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async act(options = {}) {
|
|
369
|
+
// 上一次操作是 antSelect/antTreeSelect 等下拉控件 → 先关闭残留浮层(反馈第 3 条)
|
|
370
|
+
// 跨 frame 清理:覆盖主 frame + 子应用 iframe(微前端场景)
|
|
371
|
+
if (this._lastActWasDropdown && !options.skipCloseDropdowns) {
|
|
372
|
+
try {
|
|
373
|
+
await this._closeDropdownsAllFrames(this._activeFrame || this.page.mainFrame());
|
|
374
|
+
this._lastActWasDropdown = false;
|
|
375
|
+
} catch {
|
|
376
|
+
// closeAllDropdowns 失败不影响 act 继续
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const targetFrame = await this._resolveObserveFrame(options);
|
|
381
|
+
if (options.menuPath) {
|
|
382
|
+
// 菜单在 main frame 外壳中渲染,不随子应用切换
|
|
383
|
+
const menuFrame = this.page.mainFrame();
|
|
384
|
+
const menuTarget = { frame: menuFrame, frameId: "main", matchedBy: "main (menu shell)" };
|
|
385
|
+
return this._actMenuPath(menuTarget, options);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const index = options.index !== undefined ? Number.parseInt(options.index, 10) : undefined;
|
|
389
|
+
const selector = this._resolveSelector(options.selector || options.click || options.hover);
|
|
390
|
+
const typeText = options.typeText ?? options.text;
|
|
391
|
+
const action = options.hover ? "hover" : typeText !== undefined ? "type" : "click";
|
|
392
|
+
|
|
393
|
+
const result = await targetFrame.frame.evaluate((actOptions) => {
|
|
394
|
+
function getText(el) {
|
|
395
|
+
return (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 120);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function roleOf(el) {
|
|
399
|
+
const explicit = el.getAttribute("role");
|
|
400
|
+
if (explicit) return explicit;
|
|
401
|
+
const tag = el.tagName.toLowerCase();
|
|
402
|
+
if (tag === "button") return "button";
|
|
403
|
+
if (tag === "a") return "link";
|
|
404
|
+
if (["input", "textarea"].includes(tag)) return "textbox";
|
|
405
|
+
if (tag === "select") return "combobox";
|
|
406
|
+
return "generic";
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function getName(el, text) {
|
|
410
|
+
return (
|
|
411
|
+
el.getAttribute("aria-label") ||
|
|
412
|
+
el.getAttribute("title") ||
|
|
413
|
+
el.getAttribute("placeholder") ||
|
|
414
|
+
el.getAttribute("alt") ||
|
|
415
|
+
text ||
|
|
416
|
+
""
|
|
417
|
+
).slice(0, 120);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function isCandidate(el) {
|
|
421
|
+
const tag = el.tagName.toLowerCase();
|
|
422
|
+
return ["a", "button", "input", "select", "textarea", "summary"].includes(tag) ||
|
|
423
|
+
el.getAttribute("role") ||
|
|
424
|
+
el.getAttribute("tabindex") !== null ||
|
|
425
|
+
el.onclick ||
|
|
426
|
+
el.getAttribute("data-testid") ||
|
|
427
|
+
(typeof el.className === "string" && /btn|button|menu-item|ant-btn|ant-menu|ant-select|ant-input/.test(el.className));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function interactiveElements() {
|
|
431
|
+
return Array.from(document.querySelectorAll("a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*='btn'],[class*='button'],[class*='menu-item'],[class*='ant-btn'],[class*='ant-menu'],[class*='ant-select'],[class*='ant-input']"))
|
|
432
|
+
.filter(isCandidate);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function isVisibleEnabled(el) {
|
|
436
|
+
const rect = el.getBoundingClientRect();
|
|
437
|
+
const style = window.getComputedStyle(el);
|
|
438
|
+
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
439
|
+
if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return false;
|
|
440
|
+
if (el.disabled || el.getAttribute("aria-disabled") === "true") return false;
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function findBySelector(selector) {
|
|
445
|
+
if (!selector) return null;
|
|
446
|
+
if (selector.startsWith("text=")) {
|
|
447
|
+
const expected = selector.slice(5).trim();
|
|
448
|
+
// 默认仅匹配 visible+enabled 元素,避免命中 ant-select 的隐藏 input、收起菜单里的 li
|
|
449
|
+
// 需要包含隐藏元素时显式 includeHidden=true
|
|
450
|
+
const includeHidden = actOptions.includeHidden === true;
|
|
451
|
+
const filter = (el) => includeHidden || isVisibleEnabled(el);
|
|
452
|
+
const interactive = interactiveElements().filter(filter);
|
|
453
|
+
let hit = interactive.find((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected));
|
|
454
|
+
if (hit) return hit;
|
|
455
|
+
const all = Array.from(document.querySelectorAll("*")).filter(filter);
|
|
456
|
+
return all.find((el) => getText(el).includes(expected)) || null;
|
|
457
|
+
}
|
|
458
|
+
if (selector.startsWith("role=")) {
|
|
459
|
+
const match = selector.match(/^role=([^\[]+)(?:\[name="([^"]+)"\])?/);
|
|
460
|
+
const expectedRole = match?.[1]?.trim();
|
|
461
|
+
const expectedName = match?.[2]?.trim();
|
|
462
|
+
return interactiveElements().find((el) => {
|
|
463
|
+
const text = getText(el);
|
|
464
|
+
const name = getName(el, text);
|
|
465
|
+
return roleOf(el) === expectedRole && (!expectedName || name.includes(expectedName) || text.includes(expectedName));
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
return document.querySelector(selector);
|
|
470
|
+
} catch {
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const el = actOptions.index !== undefined
|
|
476
|
+
? interactiveElements()[actOptions.index - 1]
|
|
477
|
+
: findBySelector(actOptions.selector);
|
|
478
|
+
|
|
479
|
+
if (!el) {
|
|
480
|
+
return { found: false };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
el.scrollIntoView({ block: "center", inline: "center" });
|
|
484
|
+
const rect = el.getBoundingClientRect();
|
|
485
|
+
const style = window.getComputedStyle(el);
|
|
486
|
+
const visible = rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
|
487
|
+
const enabled = !el.disabled && el.getAttribute("aria-disabled") !== "true";
|
|
488
|
+
const text = getText(el);
|
|
489
|
+
const name = getName(el, text);
|
|
490
|
+
const baseResult = {
|
|
491
|
+
found: true,
|
|
492
|
+
tagName: el.tagName,
|
|
493
|
+
role: roleOf(el),
|
|
494
|
+
name,
|
|
495
|
+
text,
|
|
496
|
+
visible,
|
|
497
|
+
enabled,
|
|
498
|
+
bbox: {
|
|
499
|
+
x: Math.round(rect.x),
|
|
500
|
+
y: Math.round(rect.y),
|
|
501
|
+
width: Math.round(rect.width),
|
|
502
|
+
height: Math.round(rect.height),
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
if (!visible || !enabled) {
|
|
507
|
+
return { ...baseResult, performed: false };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (actOptions.action === "hover") {
|
|
511
|
+
el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
|
512
|
+
el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
513
|
+
return { ...baseResult, performed: true, action: "hover" };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (actOptions.action === "type") {
|
|
517
|
+
el.focus();
|
|
518
|
+
if ("value" in el) {
|
|
519
|
+
el.value = actOptions.clear ? "" : el.value;
|
|
520
|
+
el.value += actOptions.typeText;
|
|
521
|
+
} else if (el.isContentEditable) {
|
|
522
|
+
el.textContent = actOptions.clear ? actOptions.typeText : `${el.textContent || ""}${actOptions.typeText}`;
|
|
523
|
+
} else {
|
|
524
|
+
return { ...baseResult, performed: false, error: "Target is not typeable" };
|
|
525
|
+
}
|
|
526
|
+
el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: actOptions.typeText }));
|
|
527
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
528
|
+
return { ...baseResult, performed: true, action: "type", value: "value" in el ? el.value : el.textContent };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
el.click();
|
|
532
|
+
return { ...baseResult, performed: true, action: "click" };
|
|
533
|
+
}, {
|
|
534
|
+
index,
|
|
535
|
+
selector,
|
|
536
|
+
action,
|
|
537
|
+
typeText,
|
|
538
|
+
clear: Boolean(options.clear),
|
|
539
|
+
includeHidden: Boolean(options.includeHidden),
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
if (!result.found) {
|
|
543
|
+
throw new Error(index !== undefined ? `Interactive element index not found: ${index}` : `Element not found: ${selector}`);
|
|
544
|
+
}
|
|
545
|
+
if (!result.performed) {
|
|
546
|
+
throw new Error(result.error || `Element is not actionable: ${index ?? selector}`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
acted: true,
|
|
551
|
+
action: result.action,
|
|
552
|
+
index,
|
|
553
|
+
selector,
|
|
554
|
+
target: {
|
|
555
|
+
frameId: targetFrame.frameId,
|
|
556
|
+
frameUrl: targetFrame.frame.url(),
|
|
557
|
+
matchedBy: targetFrame.matchedBy,
|
|
558
|
+
},
|
|
559
|
+
element: result,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async _actMenuPath(targetFrame, options = {}) {
|
|
564
|
+
const menuPath = Array.isArray(options.menuPath)
|
|
565
|
+
? options.menuPath
|
|
566
|
+
: String(options.menuPath).split("/").map((item) => item.trim()).filter(Boolean);
|
|
567
|
+
const waitAfterEach = Number.parseInt(options.waitAfterEach ?? 300, 10);
|
|
568
|
+
const drawerTriggerSelector = options.drawerTriggerSelector || null;
|
|
569
|
+
const drawerOpenSelector = options.drawerOpenSelector || ".ant-drawer-open, .ant-drawer.ant-drawer-open";
|
|
570
|
+
const scrollContainerSelector = options.menuScrollContainer || null;
|
|
571
|
+
const useNativeHover = options.useNativeHover !== false; // 默认开
|
|
572
|
+
const maxRetryPerStep = Number.parseInt(options.maxRetryPerStep ?? 2, 10);
|
|
573
|
+
|
|
574
|
+
if (menuPath.length === 0) {
|
|
575
|
+
throw new Error("menuPath is empty");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const frame = targetFrame.frame;
|
|
579
|
+
|
|
580
|
+
const ensureDrawerOpen = async () => {
|
|
581
|
+
if (!drawerTriggerSelector) return { ensured: false, reason: "no drawer" };
|
|
582
|
+
const isOpen = await frame.evaluate((sel) => !!document.querySelector(sel), drawerOpenSelector).catch(() => false);
|
|
583
|
+
if (isOpen) return { ensured: true, reason: "already open" };
|
|
584
|
+
const triggered = await frame.evaluate((sel) => {
|
|
585
|
+
const el = document.querySelector(sel);
|
|
586
|
+
if (!el) return false;
|
|
587
|
+
el.scrollIntoView({ block: "center", inline: "center" });
|
|
588
|
+
el.click();
|
|
589
|
+
return true;
|
|
590
|
+
}, drawerTriggerSelector).catch(() => false);
|
|
591
|
+
if (!triggered) return { ensured: false, reason: "trigger not found" };
|
|
592
|
+
await new Promise((r) => setTimeout(r, waitAfterEach));
|
|
593
|
+
return { ensured: true, reason: "clicked trigger" };
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const scrollContainerOnce = async () => {
|
|
597
|
+
if (!scrollContainerSelector) return false;
|
|
598
|
+
return frame.evaluate((sel) => {
|
|
599
|
+
const c = document.querySelector(sel);
|
|
600
|
+
if (!c) return false;
|
|
601
|
+
c.scrollTop = Math.min(c.scrollTop + Math.max(c.clientHeight - 40, 200), c.scrollHeight);
|
|
602
|
+
return true;
|
|
603
|
+
}, scrollContainerSelector).catch(() => false);
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const findStep = async (text, level) => {
|
|
607
|
+
return frame.evaluate((expectedText, lvl, totalLevels) => {
|
|
608
|
+
function textOf(el) {
|
|
609
|
+
return (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim();
|
|
610
|
+
}
|
|
611
|
+
function classText(el) {
|
|
612
|
+
return typeof el.className === "string" ? el.className : "";
|
|
613
|
+
}
|
|
614
|
+
function isVisible(el) {
|
|
615
|
+
const rect = el.getBoundingClientRect();
|
|
616
|
+
const style = window.getComputedStyle(el);
|
|
617
|
+
return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
|
|
618
|
+
}
|
|
619
|
+
function menuCandidates() {
|
|
620
|
+
return Array.from(document.querySelectorAll([
|
|
621
|
+
"[role='menuitem']",
|
|
622
|
+
".ant-menu-item",
|
|
623
|
+
".ant-menu-submenu-title",
|
|
624
|
+
"li[class*='menu']",
|
|
625
|
+
"a",
|
|
626
|
+
"button",
|
|
627
|
+
"[class*='menu-item']",
|
|
628
|
+
"[class*='submenu']",
|
|
629
|
+
].join(","))).filter(isVisible);
|
|
630
|
+
}
|
|
631
|
+
function score(el) {
|
|
632
|
+
const actual = textOf(el);
|
|
633
|
+
if (!actual) return -1;
|
|
634
|
+
let s = -1;
|
|
635
|
+
if (actual === expectedText) s = 100;
|
|
636
|
+
else if (actual.includes(expectedText)) s = 70;
|
|
637
|
+
else if (expectedText.includes(actual)) s = 50;
|
|
638
|
+
if (s < 0) return -1;
|
|
639
|
+
const cls = classText(el);
|
|
640
|
+
const role = el.getAttribute("role") || "";
|
|
641
|
+
if (role === "menuitem") s += 10;
|
|
642
|
+
if (cls.includes("ant-menu-submenu-title")) s += lvl < totalLevels - 1 ? 20 : -5;
|
|
643
|
+
if (cls.includes("ant-menu-item")) s += lvl === totalLevels - 1 ? 20 : 0;
|
|
644
|
+
if (cls.includes("ant-menu-item-selected")) s += 5;
|
|
645
|
+
return s;
|
|
646
|
+
}
|
|
647
|
+
const ranked = menuCandidates()
|
|
648
|
+
.map((el) => ({ el, s: score(el), text: textOf(el) }))
|
|
649
|
+
.filter((x) => x.s >= 0)
|
|
650
|
+
.sort((a, b) => b.s - a.s);
|
|
651
|
+
const top = ranked[0];
|
|
652
|
+
if (!top) {
|
|
653
|
+
return {
|
|
654
|
+
found: false,
|
|
655
|
+
candidates: menuCandidates().slice(0, 8).map((el) => ({
|
|
656
|
+
text: textOf(el).slice(0, 80),
|
|
657
|
+
className: classText(el).slice(0, 120),
|
|
658
|
+
})),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
// 用稳定 attr 标记,避免后续 click 时 ranking 漂移
|
|
662
|
+
top.el.setAttribute("data-szcd-menu-target", "1");
|
|
663
|
+
const rect = top.el.getBoundingClientRect();
|
|
664
|
+
return {
|
|
665
|
+
found: true,
|
|
666
|
+
matchedText: top.text,
|
|
667
|
+
bbox: {
|
|
668
|
+
x: Math.round(rect.x), y: Math.round(rect.y),
|
|
669
|
+
width: Math.round(rect.width), height: Math.round(rect.height),
|
|
670
|
+
},
|
|
671
|
+
snapshot: {
|
|
672
|
+
tagName: top.el.tagName,
|
|
673
|
+
text: textOf(top.el).slice(0, 120),
|
|
674
|
+
role: top.el.getAttribute("role") || null,
|
|
675
|
+
className: classText(top.el),
|
|
676
|
+
ariaExpanded: top.el.getAttribute("aria-expanded"),
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
}, text, level, menuPath.length).catch(() => ({ found: false, candidates: [] }));
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
const clickStep = async (isLast) => {
|
|
683
|
+
return frame.evaluate(async (waitMs, last) => {
|
|
684
|
+
const el = document.querySelector("[data-szcd-menu-target='1']");
|
|
685
|
+
if (!el) return { performed: false, error: "marker lost" };
|
|
686
|
+
el.scrollIntoView({ block: "center", inline: "center" });
|
|
687
|
+
const beforeUrl = location.href;
|
|
688
|
+
const beforeText = document.body?.innerText || "";
|
|
689
|
+
el.click();
|
|
690
|
+
el.removeAttribute("data-szcd-menu-target");
|
|
691
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
692
|
+
const cls = typeof el.className === "string" ? el.className : "";
|
|
693
|
+
return {
|
|
694
|
+
performed: true,
|
|
695
|
+
action: last ? "click" : "expand",
|
|
696
|
+
after: {
|
|
697
|
+
className: cls,
|
|
698
|
+
ariaExpanded: el.getAttribute("aria-expanded"),
|
|
699
|
+
urlChanged: location.href !== beforeUrl,
|
|
700
|
+
textChanged: (document.body?.innerText || "") !== beforeText,
|
|
701
|
+
selected: cls.includes("selected") || cls.includes("active"),
|
|
702
|
+
},
|
|
703
|
+
};
|
|
704
|
+
}, waitAfterEach, isLast);
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
const steps = [];
|
|
708
|
+
|
|
709
|
+
for (let i = 0; i < menuPath.length; i++) {
|
|
710
|
+
const text = menuPath[i];
|
|
711
|
+
const isLast = i === menuPath.length - 1;
|
|
712
|
+
|
|
713
|
+
// 抽屉式菜单:每级前都先确认抽屉是开的
|
|
714
|
+
const drawerState = await ensureDrawerOpen();
|
|
715
|
+
|
|
716
|
+
let match = await findStep(text, i);
|
|
717
|
+
let attempts = 0;
|
|
718
|
+
while (!match.found && attempts < maxRetryPerStep) {
|
|
719
|
+
attempts += 1;
|
|
720
|
+
// 滚动一次再找
|
|
721
|
+
const scrolled = await scrollContainerOnce();
|
|
722
|
+
if (!scrolled) await new Promise((r) => setTimeout(r, waitAfterEach));
|
|
723
|
+
match = await findStep(text, i);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (!match.found) {
|
|
727
|
+
return {
|
|
728
|
+
acted: false,
|
|
729
|
+
action: "menuPath",
|
|
730
|
+
menuPath,
|
|
731
|
+
target: {
|
|
732
|
+
frameId: targetFrame.frameId,
|
|
733
|
+
frameUrl: targetFrame.frame.url(),
|
|
734
|
+
matchedBy: targetFrame.matchedBy,
|
|
735
|
+
},
|
|
736
|
+
failedAt: text,
|
|
737
|
+
failedAtIndex: i,
|
|
738
|
+
candidates: match.candidates || [],
|
|
739
|
+
drawerState,
|
|
740
|
+
steps,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// native hover(puppeteer frame.hover),优于 dispatchEvent
|
|
745
|
+
if (useNativeHover && typeof frame.hover === "function") {
|
|
746
|
+
try {
|
|
747
|
+
await frame.hover("[data-szcd-menu-target='1']");
|
|
748
|
+
} catch {
|
|
749
|
+
// hover 失败不致命,继续 click
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const clickRes = await clickStep(isLast);
|
|
754
|
+
steps.push({
|
|
755
|
+
text,
|
|
756
|
+
action: clickRes.action || (isLast ? "click" : "expand"),
|
|
757
|
+
status: clickRes.performed ? "PASS" : "FAIL",
|
|
758
|
+
matchedText: match.matchedText,
|
|
759
|
+
before: match.snapshot,
|
|
760
|
+
after: clickRes.after || null,
|
|
761
|
+
drawerEnsured: drawerState.ensured,
|
|
762
|
+
retries: attempts,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const currentUrl = this.page.url();
|
|
767
|
+
const title = await this.page.title().catch(() => "");
|
|
768
|
+
|
|
769
|
+
return {
|
|
770
|
+
acted: true,
|
|
771
|
+
action: "menuPath",
|
|
772
|
+
menuPath,
|
|
773
|
+
target: {
|
|
774
|
+
frameId: targetFrame.frameId,
|
|
775
|
+
frameUrl: targetFrame.frame.url(),
|
|
776
|
+
matchedBy: targetFrame.matchedBy,
|
|
777
|
+
},
|
|
778
|
+
steps,
|
|
779
|
+
currentUrl,
|
|
780
|
+
title,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async assert(options = {}) {
|
|
785
|
+
const targetFrame = await this._resolveObserveFrame(options);
|
|
786
|
+
const previousFrame = this._activeFrame;
|
|
787
|
+
this._activeFrame = targetFrame.frame === this.page.mainFrame() ? null : targetFrame.frame;
|
|
788
|
+
|
|
789
|
+
try {
|
|
790
|
+
const checks = {};
|
|
791
|
+
let passed = true;
|
|
792
|
+
|
|
793
|
+
if (options.selector) {
|
|
794
|
+
const elementResult = await this._checkElement({ selector: options.selector, expect: options.expect || {} });
|
|
795
|
+
checks.element = elementResult;
|
|
796
|
+
if (!elementResult.passed) passed = false;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (options.urlContains) {
|
|
800
|
+
const currentUrl = this.page.url();
|
|
801
|
+
checks.urlContains = { expected: options.urlContains, actual: currentUrl, passed: currentUrl.includes(options.urlContains) };
|
|
802
|
+
if (!checks.urlContains.passed) passed = false;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (options.textContains) {
|
|
806
|
+
const textResult = await this.safeEval((text) => {
|
|
807
|
+
const bodyText = document.body?.innerText || "";
|
|
808
|
+
return { expected: text, passed: bodyText.includes(text) };
|
|
809
|
+
}, options.textContains);
|
|
810
|
+
checks.textContains = textResult;
|
|
811
|
+
if (!textResult.passed) passed = false;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return {
|
|
815
|
+
passed,
|
|
816
|
+
target: {
|
|
817
|
+
frameId: targetFrame.frameId,
|
|
818
|
+
frameUrl: targetFrame.frame.url(),
|
|
819
|
+
matchedBy: targetFrame.matchedBy,
|
|
820
|
+
},
|
|
821
|
+
checks,
|
|
822
|
+
};
|
|
823
|
+
} finally {
|
|
824
|
+
this._activeFrame = previousFrame;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async captureApi(options = {}) {
|
|
829
|
+
const wait = Number.parseInt(options.wait ?? options.timeout ?? 15000, 10);
|
|
830
|
+
const includeRequestBody = options.includeRequestBody !== false;
|
|
831
|
+
const includeResponseBody = Boolean(options.includeResponseBody);
|
|
832
|
+
const methodFilter = options.method ? String(options.method).toUpperCase() : null;
|
|
833
|
+
const urlPattern = options.urlPattern || options.urlIncludes || "";
|
|
834
|
+
const targetFrame = await this._resolveObserveFrame(options).catch(() => null);
|
|
835
|
+
const requests = new Map();
|
|
836
|
+
const sessions = [];
|
|
837
|
+
const targets = [];
|
|
838
|
+
const pendingBodies = [];
|
|
839
|
+
const startedAt = Date.now();
|
|
840
|
+
|
|
841
|
+
const matchesUrl = (url = "") => !urlPattern || url.includes(urlPattern);
|
|
842
|
+
const matchesMethod = (method = "") => !methodFilter || method.toUpperCase() === methodFilter;
|
|
843
|
+
const normalizeHeaders = (headers = {}) => Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, String(value)]));
|
|
844
|
+
|
|
845
|
+
const addSession = async (session, targetInfo) => {
|
|
846
|
+
await session.send("Network.enable").catch(() => null);
|
|
847
|
+
sessions.push(session);
|
|
848
|
+
targets.push(targetInfo);
|
|
849
|
+
|
|
850
|
+
session.on("Network.requestWillBeSent", (event) => {
|
|
851
|
+
const request = event.request || {};
|
|
852
|
+
if (!matchesUrl(request.url) || !matchesMethod(request.method)) return;
|
|
853
|
+
// 为每条请求记录 frameUrl:关键的是框架帧的 URL,不是发起者 iframe 的 document
|
|
854
|
+
// event.frameId / event.loaderId 可关联到 Page.frameNavigated 事件
|
|
855
|
+
requests.set(`${targetInfo.id}:${event.requestId}`, {
|
|
856
|
+
id: event.requestId,
|
|
857
|
+
targetId: targetInfo.id,
|
|
858
|
+
targetType: targetInfo.type,
|
|
859
|
+
targetUrl: targetInfo.url,
|
|
860
|
+
frameId: event.frameId,
|
|
861
|
+
frameUrl: targetInfo.url, // 请求来源的 frame URL
|
|
862
|
+
url: request.url,
|
|
863
|
+
method: request.method,
|
|
864
|
+
requestHeaders: normalizeHeaders(request.headers),
|
|
865
|
+
requestBody: includeRequestBody ? request.postData || null : undefined,
|
|
866
|
+
status: null,
|
|
867
|
+
responseHeaders: null,
|
|
868
|
+
responseBody: null,
|
|
869
|
+
responseBodyBase64Encoded: null,
|
|
870
|
+
errorText: null,
|
|
871
|
+
startTime: Date.now(),
|
|
872
|
+
endTime: null,
|
|
873
|
+
duration: null,
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
session.on("Network.responseReceived", (event) => {
|
|
878
|
+
const item = requests.get(`${targetInfo.id}:${event.requestId}`);
|
|
879
|
+
if (!item) return;
|
|
880
|
+
const response = event.response || {};
|
|
881
|
+
item.status = response.status;
|
|
882
|
+
item.mimeType = response.mimeType;
|
|
883
|
+
item.responseHeaders = normalizeHeaders(response.headers);
|
|
884
|
+
item.responseUrl = response.url;
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
session.on("Network.loadingFinished", async (event) => {
|
|
888
|
+
const item = requests.get(`${targetInfo.id}:${event.requestId}`);
|
|
889
|
+
if (!item) return;
|
|
890
|
+
item.endTime = Date.now();
|
|
891
|
+
item.duration = item.endTime - item.startTime;
|
|
892
|
+
if (includeResponseBody) {
|
|
893
|
+
const bodyTask = session.send("Network.getResponseBody", { requestId: event.requestId })
|
|
894
|
+
.then((body) => {
|
|
895
|
+
item.responseBody = body.body;
|
|
896
|
+
item.responseBodyBase64Encoded = body.base64Encoded;
|
|
897
|
+
})
|
|
898
|
+
.catch((err) => {
|
|
899
|
+
item.responseBodyError = err.message;
|
|
900
|
+
});
|
|
901
|
+
pendingBodies.push(bodyTask);
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
session.on("Network.loadingFailed", (event) => {
|
|
906
|
+
const item = requests.get(`${targetInfo.id}:${event.requestId}`);
|
|
907
|
+
if (!item) return;
|
|
908
|
+
item.endTime = Date.now();
|
|
909
|
+
item.duration = item.endTime - item.startTime;
|
|
910
|
+
item.errorText = event.errorText || "loadingFailed";
|
|
911
|
+
});
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const pageSession = await this.page.target().createCDPSession();
|
|
915
|
+
await addSession(pageSession, {
|
|
916
|
+
id: "page",
|
|
917
|
+
type: "page",
|
|
918
|
+
url: this.page.url(),
|
|
919
|
+
matchedBy: "currentPage",
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
const pageTarget = this.page.target();
|
|
923
|
+
const pickCandidates = () => this.browser.targets().filter((target) => {
|
|
924
|
+
if (target === pageTarget) return false;
|
|
925
|
+
if (!["iframe", "page"].includes(target.type())) return false;
|
|
926
|
+
const targetUrl = target.url() || "";
|
|
927
|
+
if (options.targetUrlContains && !targetUrl.includes(options.targetUrlContains)) return false;
|
|
928
|
+
if (targetFrame?.frame && targetUrl && targetFrame.frame.url() && targetUrl !== targetFrame.frame.url() && !targetUrl.includes(targetFrame.frame.url())) {
|
|
929
|
+
return options.includeAllTargets === true;
|
|
930
|
+
}
|
|
931
|
+
return true;
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
const attachOnce = async (candidates) => {
|
|
935
|
+
for (const target of candidates) {
|
|
936
|
+
const session = await target.createCDPSession().catch(() => null);
|
|
937
|
+
if (!session) continue;
|
|
938
|
+
await addSession(session, {
|
|
939
|
+
id: target.url() || `${target.type()}-${targets.length}`,
|
|
940
|
+
type: target.type(),
|
|
941
|
+
url: target.url() || "",
|
|
942
|
+
matchedBy: "browser.targets",
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
// 微前端 wujie/qiankun 时序:iframe target 可能在主页面 ready 后才注册
|
|
948
|
+
// attach=0 时延迟重试,最多 3 次(含首次)
|
|
949
|
+
const maxAttachTries = Number.parseInt(options.attachRetries ?? 3, 10);
|
|
950
|
+
const attachRetryDelay = Number.parseInt(options.attachRetryDelay ?? 1500, 10);
|
|
951
|
+
let attachCandidates = pickCandidates();
|
|
952
|
+
let tries = 1;
|
|
953
|
+
await attachOnce(attachCandidates);
|
|
954
|
+
const attachLog = [{ try: 1, count: attachCandidates.length }];
|
|
955
|
+
while (attachCandidates.length === 0 && tries < maxAttachTries) {
|
|
956
|
+
tries += 1;
|
|
957
|
+
await new Promise((r) => setTimeout(r, attachRetryDelay));
|
|
958
|
+
attachCandidates = pickCandidates();
|
|
959
|
+
attachLog.push({ try: tries, count: attachCandidates.length });
|
|
960
|
+
await attachOnce(attachCandidates);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (options.reload) {
|
|
964
|
+
await this.page.reload({ waitUntil: options.waitUntil || "domcontentloaded", timeout: options.reloadTimeout || 30000 }).catch(() => null);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
968
|
+
await Promise.allSettled(pendingBodies);
|
|
969
|
+
|
|
970
|
+
await Promise.all(sessions.map((session) => session.send("Network.disable").catch(() => null)));
|
|
971
|
+
|
|
972
|
+
const requestList = Array.from(requests.values()).sort((a, b) => a.startTime - b.startTime);
|
|
973
|
+
const title = await this.page.title().catch(() => "");
|
|
974
|
+
const failed = requestList.filter((item) => item.errorText || (item.status && item.status >= 400));
|
|
975
|
+
|
|
976
|
+
return {
|
|
977
|
+
type: "apiCapture",
|
|
978
|
+
page: {
|
|
979
|
+
url: this.page.url(),
|
|
980
|
+
title,
|
|
981
|
+
},
|
|
982
|
+
target: targetFrame ? {
|
|
983
|
+
frameId: targetFrame.frameId,
|
|
984
|
+
frameUrl: targetFrame.frame.url(),
|
|
985
|
+
matchedBy: targetFrame.matchedBy,
|
|
986
|
+
} : null,
|
|
987
|
+
targets,
|
|
988
|
+
requests: requestList,
|
|
989
|
+
summary: {
|
|
990
|
+
total: requestList.length,
|
|
991
|
+
success: requestList.length - failed.length,
|
|
992
|
+
failed: failed.length,
|
|
993
|
+
targets: targets.length,
|
|
994
|
+
attachLog,
|
|
995
|
+
duration: Date.now() - startedAt,
|
|
996
|
+
},
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
assertApi(captureResult, options = {}) {
|
|
1001
|
+
if (!captureResult || !Array.isArray(captureResult.requests)) {
|
|
1002
|
+
throw new Error("assertApi requires an apiCapture result");
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const requests = captureResult.requests;
|
|
1006
|
+
const expect = options.expect || options;
|
|
1007
|
+
const checks = {};
|
|
1008
|
+
let passed = true;
|
|
1009
|
+
|
|
1010
|
+
if (expect.maxFailed !== undefined) {
|
|
1011
|
+
const actual = requests.filter((item) => item.errorText || (item.status && item.status >= 400)).length;
|
|
1012
|
+
checks.maxFailed = {
|
|
1013
|
+
expected: Number.parseInt(expect.maxFailed, 10),
|
|
1014
|
+
actual,
|
|
1015
|
+
passed: actual <= Number.parseInt(expect.maxFailed, 10),
|
|
1016
|
+
};
|
|
1017
|
+
if (!checks.maxFailed.passed) passed = false;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (expect.forbidStatusGte !== undefined) {
|
|
1021
|
+
const threshold = Number.parseInt(expect.forbidStatusGte, 10);
|
|
1022
|
+
const violations = requests
|
|
1023
|
+
.filter((item) => item.status && item.status >= threshold)
|
|
1024
|
+
.map((item) => ({ url: item.url, method: item.method, status: item.status }));
|
|
1025
|
+
checks.forbidStatusGte = {
|
|
1026
|
+
expected: threshold,
|
|
1027
|
+
violations,
|
|
1028
|
+
passed: violations.length === 0,
|
|
1029
|
+
};
|
|
1030
|
+
if (!checks.forbidStatusGte.passed) passed = false;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
if (expect.requiredUrls !== undefined) {
|
|
1034
|
+
const requiredUrls = Array.isArray(expect.requiredUrls) ? expect.requiredUrls : [expect.requiredUrls];
|
|
1035
|
+
checks.requiredUrls = requiredUrls.map((url) => {
|
|
1036
|
+
const matched = requests.filter((item) => item.url?.includes(url));
|
|
1037
|
+
return {
|
|
1038
|
+
url,
|
|
1039
|
+
matched: matched.length,
|
|
1040
|
+
passed: matched.length > 0,
|
|
1041
|
+
};
|
|
1042
|
+
});
|
|
1043
|
+
if (checks.requiredUrls.some((item) => !item.passed)) passed = false;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (expect.requiredMethods !== undefined) {
|
|
1047
|
+
const requiredMethods = Array.isArray(expect.requiredMethods) ? expect.requiredMethods : [expect.requiredMethods];
|
|
1048
|
+
checks.requiredMethods = requiredMethods.map((method) => {
|
|
1049
|
+
const normalized = String(method).toUpperCase();
|
|
1050
|
+
const matched = requests.filter((item) => item.method === normalized);
|
|
1051
|
+
return {
|
|
1052
|
+
method: normalized,
|
|
1053
|
+
matched: matched.length,
|
|
1054
|
+
passed: matched.length > 0,
|
|
1055
|
+
};
|
|
1056
|
+
});
|
|
1057
|
+
if (checks.requiredMethods.some((item) => !item.passed)) passed = false;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (expect.minTotal !== undefined) {
|
|
1061
|
+
const expected = Number.parseInt(expect.minTotal, 10);
|
|
1062
|
+
checks.minTotal = {
|
|
1063
|
+
expected,
|
|
1064
|
+
actual: requests.length,
|
|
1065
|
+
passed: requests.length >= expected,
|
|
1066
|
+
};
|
|
1067
|
+
if (!checks.minTotal.passed) passed = false;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
return {
|
|
1071
|
+
type: "assertApi",
|
|
1072
|
+
passed,
|
|
1073
|
+
checks,
|
|
1074
|
+
summary: captureResult?.summary || {
|
|
1075
|
+
total: requests.length,
|
|
1076
|
+
failed: requests.filter((item) => item.errorText || (item.status && item.status >= 400)).length,
|
|
1077
|
+
},
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
async _resolveObserveFrame(options = {}) {
|
|
1082
|
+
// testCase setup 已预定位 iframe,且调用方未显式传 frame 参数 → 直接复用
|
|
1083
|
+
if (this._activeFrame && options.frameIndex === undefined && !options.frameUrlContains && !options.frameContentContains) {
|
|
1084
|
+
const frames = this.page.frames();
|
|
1085
|
+
const idx = frames.indexOf(this._activeFrame);
|
|
1086
|
+
if (idx >= 0) {
|
|
1087
|
+
return { frame: this._activeFrame, frameId: idx === 0 ? "main" : `frame-${idx}`, matchedBy: "activeFrame (from testCase setup)" };
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const frames = this.page.frames();
|
|
1092
|
+
const mainFrame = this.page.mainFrame();
|
|
1093
|
+
|
|
1094
|
+
if (options.frameIndex !== undefined) {
|
|
1095
|
+
const index = Number.parseInt(options.frameIndex, 10);
|
|
1096
|
+
if (frames[index]) {
|
|
1097
|
+
return { frame: frames[index], frameId: index === 0 ? "main" : `frame-${index}`, matchedBy: `frameIndex ${index}` };
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (options.frameUrlContains) {
|
|
1102
|
+
const index = frames.findIndex((frame) => frame.url().includes(options.frameUrlContains));
|
|
1103
|
+
if (index >= 0) {
|
|
1104
|
+
return { frame: frames[index], frameId: index === 0 ? "main" : `frame-${index}`, matchedBy: `url contains "${options.frameUrlContains}"` };
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
if (options.frameContentContains) {
|
|
1109
|
+
for (let i = 0; i < frames.length; i++) {
|
|
1110
|
+
const matched = await frames[i].evaluate((text) =>
|
|
1111
|
+
document.body?.innerText?.includes(text) || false,
|
|
1112
|
+
options.frameContentContains
|
|
1113
|
+
).catch(() => false);
|
|
1114
|
+
if (matched) {
|
|
1115
|
+
return { frame: frames[i], frameId: i === 0 ? "main" : `frame-${i}`, matchedBy: `body contains "${options.frameContentContains}"` };
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// 无显式 frame 参数时:WebComponent 模式默认用 sandbox iframe(含子应用实际 DOM)
|
|
1121
|
+
if (this._wujieContext?.type === 'webcomponent' && frames.length > 1) {
|
|
1122
|
+
const sandboxIdx = frames.findIndex((f) => f !== mainFrame && !f.url().startsWith('about:'));
|
|
1123
|
+
if (sandboxIdx >= 0) {
|
|
1124
|
+
return { frame: frames[sandboxIdx], frameId: `frame-${sandboxIdx}`, matchedBy: 'webcomponent sandbox iframe' };
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const mainIndex = frames.indexOf(mainFrame);
|
|
1129
|
+
return { frame: mainFrame, frameId: mainIndex <= 0 ? "main" : `frame-${mainIndex}`, matchedBy: "main" };
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
async _getFrameSummaries() {
|
|
1133
|
+
const frames = this.page.frames();
|
|
1134
|
+
const mainFrame = this.page.mainFrame();
|
|
1135
|
+
const summaries = [];
|
|
1136
|
+
for (let i = 0; i < frames.length; i++) {
|
|
1137
|
+
const frame = frames[i];
|
|
1138
|
+
const title = await frame.evaluate(() => document.title || "").catch(() => "");
|
|
1139
|
+
summaries.push({
|
|
1140
|
+
frameId: frame === mainFrame ? "main" : `frame-${i}`,
|
|
1141
|
+
url: frame.url(),
|
|
1142
|
+
title,
|
|
1143
|
+
isMain: frame === mainFrame,
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
return summaries;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
async _buildDomTree(frame, selector, depth) {
|
|
1150
|
+
return frame.evaluate((rootSelector, maxDepth) => {
|
|
1151
|
+
function attrsOf(el) {
|
|
1152
|
+
const attrs = {};
|
|
1153
|
+
for (const attr of Array.from(el.attributes || [])) {
|
|
1154
|
+
if (["id", "class", "role", "aria-label", "title", "placeholder", "href", "src", "data-testid"].includes(attr.name)) {
|
|
1155
|
+
attrs[attr.name] = attr.value.slice(0, 120);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
return attrs;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function directTextOf(el) {
|
|
1162
|
+
return Array.from(el.childNodes || [])
|
|
1163
|
+
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
|
1164
|
+
.map((node) => node.textContent.trim())
|
|
1165
|
+
.filter(Boolean)
|
|
1166
|
+
.join(" ")
|
|
1167
|
+
.slice(0, 120);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function build(el, currentDepth) {
|
|
1171
|
+
if (!el || currentDepth > maxDepth) return null;
|
|
1172
|
+
const tag = el.tagName?.toLowerCase();
|
|
1173
|
+
if (!tag || ["script", "style", "path"].includes(tag)) return null;
|
|
1174
|
+
const rect = el.getBoundingClientRect();
|
|
1175
|
+
const node = {
|
|
1176
|
+
tag,
|
|
1177
|
+
attrs: attrsOf(el),
|
|
1178
|
+
text: directTextOf(el),
|
|
1179
|
+
visible: rect.width > 0 && rect.height > 0,
|
|
1180
|
+
children: [],
|
|
1181
|
+
};
|
|
1182
|
+
for (const child of Array.from(el.children || [])) {
|
|
1183
|
+
const childNode = build(child, currentDepth + 1);
|
|
1184
|
+
if (childNode) node.children.push(childNode);
|
|
1185
|
+
}
|
|
1186
|
+
return node;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
return build(document.querySelector(rootSelector) || document.body, 0);
|
|
1190
|
+
}, selector, depth).catch((err) => ({ error: err.message, tag: "error", attrs: {}, text: "", visible: false, children: [] }));
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
async _collectInteractiveElements(frame, frameId, maxElements) {
|
|
1194
|
+
return frame.evaluate((currentFrameId, limit) => {
|
|
1195
|
+
function cssEscape(value) {
|
|
1196
|
+
if (window.CSS?.escape) return window.CSS.escape(value);
|
|
1197
|
+
return String(value).replace(/["'\\]/g, "\\$&");
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function getText(el) {
|
|
1201
|
+
return (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 120);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function getName(el, text) {
|
|
1205
|
+
return (
|
|
1206
|
+
el.getAttribute("aria-label") ||
|
|
1207
|
+
el.getAttribute("title") ||
|
|
1208
|
+
el.getAttribute("placeholder") ||
|
|
1209
|
+
el.getAttribute("alt") ||
|
|
1210
|
+
text ||
|
|
1211
|
+
""
|
|
1212
|
+
).slice(0, 120);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function roleOf(el) {
|
|
1216
|
+
const explicit = el.getAttribute("role");
|
|
1217
|
+
if (explicit) return explicit;
|
|
1218
|
+
const tag = el.tagName.toLowerCase();
|
|
1219
|
+
if (tag === "button") return "button";
|
|
1220
|
+
if (tag === "a") return "link";
|
|
1221
|
+
if (["input", "textarea"].includes(tag)) return "textbox";
|
|
1222
|
+
if (tag === "select") return "combobox";
|
|
1223
|
+
return "generic";
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function selectorCandidates(el, role, name, text) {
|
|
1227
|
+
const selectors = [];
|
|
1228
|
+
const testId = el.getAttribute("data-testid");
|
|
1229
|
+
if (testId) selectors.push(`[data-testid="${cssEscape(testId)}"]`);
|
|
1230
|
+
if (el.id) selectors.push(`#${cssEscape(el.id)}`);
|
|
1231
|
+
if (role && role !== "generic" && name) selectors.push(`role=${role}[name="${name.replace(/"/g, "\\\"")}"]`);
|
|
1232
|
+
if (text) selectors.push(`text=${text.replace(/"/g, "\\\"")}`);
|
|
1233
|
+
const tag = el.tagName.toLowerCase();
|
|
1234
|
+
const className = typeof el.className === "string" ? el.className.trim().split(/\s+/).filter(Boolean)[0] : "";
|
|
1235
|
+
if (className) selectors.push(`${tag}.${cssEscape(className)}`);
|
|
1236
|
+
selectors.push(tag);
|
|
1237
|
+
return [...new Set(selectors)].slice(0, 5);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function isCandidate(el) {
|
|
1241
|
+
const tag = el.tagName.toLowerCase();
|
|
1242
|
+
return ["a", "button", "input", "select", "textarea", "summary"].includes(tag) ||
|
|
1243
|
+
el.getAttribute("role") ||
|
|
1244
|
+
el.getAttribute("tabindex") !== null ||
|
|
1245
|
+
el.onclick ||
|
|
1246
|
+
el.getAttribute("data-testid") ||
|
|
1247
|
+
(typeof el.className === "string" && /btn|button|menu-item|ant-btn|ant-menu|ant-select|ant-input/.test(el.className));
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
|
1251
|
+
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
1252
|
+
return Array.from(document.querySelectorAll("a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*='btn'],[class*='button'],[class*='menu-item'],[class*='ant-btn'],[class*='ant-menu'],[class*='ant-select'],[class*='ant-input']"))
|
|
1253
|
+
.filter(isCandidate)
|
|
1254
|
+
.slice(0, limit)
|
|
1255
|
+
.map((el, idx) => {
|
|
1256
|
+
const rect = el.getBoundingClientRect();
|
|
1257
|
+
const style = window.getComputedStyle(el);
|
|
1258
|
+
const text = getText(el);
|
|
1259
|
+
const role = roleOf(el);
|
|
1260
|
+
const name = getName(el, text);
|
|
1261
|
+
const visible = rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
|
1262
|
+
return {
|
|
1263
|
+
index: idx + 1,
|
|
1264
|
+
frameId: currentFrameId,
|
|
1265
|
+
tag: el.tagName.toLowerCase(),
|
|
1266
|
+
role,
|
|
1267
|
+
name,
|
|
1268
|
+
text,
|
|
1269
|
+
selectorCandidates: selectorCandidates(el, role, name, text),
|
|
1270
|
+
bbox: {
|
|
1271
|
+
x: Math.round(rect.x),
|
|
1272
|
+
y: Math.round(rect.y),
|
|
1273
|
+
width: Math.round(rect.width),
|
|
1274
|
+
height: Math.round(rect.height),
|
|
1275
|
+
},
|
|
1276
|
+
state: {
|
|
1277
|
+
visible,
|
|
1278
|
+
enabled: !el.disabled && el.getAttribute("aria-disabled") !== "true",
|
|
1279
|
+
inViewport: rect.bottom >= 0 && rect.right >= 0 && rect.top <= viewportHeight && rect.left <= viewportWidth,
|
|
1280
|
+
},
|
|
1281
|
+
};
|
|
1282
|
+
});
|
|
1283
|
+
}, frameId, maxElements).catch(() => []);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* 步骤分发
|
|
1288
|
+
*/
|
|
1289
|
+
async _dispatchStep(step, context) {
|
|
1290
|
+
switch (step.type) {
|
|
1291
|
+
case "observe": return this.observe(step);
|
|
1292
|
+
case "act": return this.act(step);
|
|
1293
|
+
case "assert": return this.assert(step);
|
|
1294
|
+
case "apiCapture": return this.captureApi(step);
|
|
1295
|
+
case "api-capture": return this.captureApi(step);
|
|
1296
|
+
case "assertApi": return this.assertApi(step.captureResult || context.lastApiCapture, step);
|
|
1297
|
+
case "assert-api": return this.assertApi(step.captureResult || context.lastApiCapture, step);
|
|
1298
|
+
case "navigate": return this._navigate(step);
|
|
1299
|
+
case "screenshot": return this._screenshot(step, context);
|
|
1300
|
+
case "compare": return this._compare(step, context);
|
|
1301
|
+
case "click": return this._click(step);
|
|
1302
|
+
case "type": return this._type(step);
|
|
1303
|
+
case "waitFor": return this._waitFor(step);
|
|
1304
|
+
case "evaluate": return this._evaluate(step);
|
|
1305
|
+
case "checkElement": return this._checkElement(step);
|
|
1306
|
+
case "findPage": return this._findPage(step);
|
|
1307
|
+
case "findFrame": return this._findFrame(step);
|
|
1308
|
+
case "loginWait": return this._loginWait(step);
|
|
1309
|
+
case "aiAssert": return this._aiAssert(step, context);
|
|
1310
|
+
case "runTask": return this.runTask(step, context);
|
|
1311
|
+
case "run-task": return this.runTask(step, context);
|
|
1312
|
+
// ===== Ant Design 适配层(6/22 反馈落地) =====
|
|
1313
|
+
case "antSelect": return this._antSelect(step);
|
|
1314
|
+
case "antTreeSelect":return this._antTreeSelect(step);
|
|
1315
|
+
case "antInput": return this._antInput(step);
|
|
1316
|
+
case "antRadio": return this._antRadio(step);
|
|
1317
|
+
case "antCheckbox": return this._antCheckbox(step);
|
|
1318
|
+
case "antSwitch": return this._antSwitch(step);
|
|
1319
|
+
case "antDatePicker":return this._antDatePicker(step);
|
|
1320
|
+
case "antCascader": return this._antCascader(step);
|
|
1321
|
+
case "antUpload": return this._antUpload(step);
|
|
1322
|
+
case "formFill": return this._formFill(step);
|
|
1323
|
+
case "closeAllDropdowns": return this._closeAllDropdowns(step);
|
|
1324
|
+
// ===== 语义断言层 =====
|
|
1325
|
+
case "expect": return this._expect(step);
|
|
1326
|
+
default:
|
|
1327
|
+
throw new Error(`Unknown step type: ${step.type}`);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* 解析目标 frame,优先级(微前端场景兼容):
|
|
1333
|
+
* 1. step 显式传 frameContentContains / frameUrlContains / frameIndex → 用 _resolveObserveFrame
|
|
1334
|
+
* 2. 已有 _activeFrame(来自 setup.findFrame / 上一步 findFrame)→ 直接复用
|
|
1335
|
+
* 3. 否则回退到 mainFrame
|
|
1336
|
+
*
|
|
1337
|
+
* 同时做"frame 探活 + 自动召回":如果 _activeFrame 因子应用卸载而失效,
|
|
1338
|
+
* 自动清空并回退到 mainFrame,避免无限挂在已销毁的 frame 上。
|
|
1339
|
+
*
|
|
1340
|
+
* @returns {Promise<{frame, frameId, matchedBy, frameRecovered?}>} 含 frameRecovered 标志
|
|
1341
|
+
*/
|
|
1342
|
+
async _getTargetFrame(step) {
|
|
1343
|
+
const hasExplicitFrame = step && (step.frameContentContains || step.frameUrlContains || step.frameIndex !== undefined);
|
|
1344
|
+
|
|
1345
|
+
// 路径 1:显式指定 frame → 总是用 _resolveObserveFrame
|
|
1346
|
+
if (hasExplicitFrame) {
|
|
1347
|
+
const resolved = await this._resolveObserveFrame(step);
|
|
1348
|
+
return { frame: resolved.frame, frameId: resolved.frameId, matchedBy: resolved.matchedBy, frameRecovered: false };
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// 路径 2:复用 _activeFrame(微前端场景的核心优化)
|
|
1352
|
+
if (this._activeFrame) {
|
|
1353
|
+
// 探活:确认 frame 还能 evaluate(未被子应用卸载)
|
|
1354
|
+
const alive = await this._activeFrame.evaluate(() => true).catch(() => false);
|
|
1355
|
+
if (alive) {
|
|
1356
|
+
return { frame: this._activeFrame, frameId: "active", matchedBy: "_activeFrame", frameRecovered: false };
|
|
1357
|
+
}
|
|
1358
|
+
// 失活:清空 _activeFrame,召回到 mainFrame
|
|
1359
|
+
this._activeFrame = null;
|
|
1360
|
+
return { frame: this.page.mainFrame(), frameId: "main", matchedBy: "_activeFrame failed, fallback to main", frameRecovered: true };
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// 路径 3:mainFrame
|
|
1364
|
+
const resolved = await this._resolveObserveFrame(step || {});
|
|
1365
|
+
return { frame: resolved.frame, frameId: resolved.frameId, matchedBy: resolved.matchedBy, frameRecovered: false };
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
async _antSelect(step) {
|
|
1369
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1370
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1371
|
+
let result = await adapter.select(frame, step.field || step.label, step.option || step.value, {
|
|
1372
|
+
mode: step.mode,
|
|
1373
|
+
timeout: step.timeout,
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
// 微前端跨 frame 浮层兜底:
|
|
1377
|
+
// 如果当前 frame 未找到浮层选项(getPopupContainer 指向主文档),尝试主 frame
|
|
1378
|
+
if (!result.acted && result.reason?.includes?.("not found in dropdown")) {
|
|
1379
|
+
const mainFrame = this.page.mainFrame();
|
|
1380
|
+
if (mainFrame !== frame) {
|
|
1381
|
+
const retry = await adapter.select(mainFrame, step.field || step.label, step.option || step.value, {
|
|
1382
|
+
mode: step.mode,
|
|
1383
|
+
timeout: step.timeout,
|
|
1384
|
+
});
|
|
1385
|
+
if (retry.acted) {
|
|
1386
|
+
result = retry;
|
|
1387
|
+
result._dropdownFrame = "main";
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
this._lastActWasDropdown = true;
|
|
1393
|
+
return { type: "antSelect", passed: result.acted && result.verified !== false, ...result };
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
async _antTreeSelect(step) {
|
|
1397
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1398
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1399
|
+
let result = await adapter.treeSelect(frame, step.field || step.label, step.path || step.value, {
|
|
1400
|
+
timeout: step.timeout,
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
// 微前端跨 frame 浮层兜底:同上
|
|
1404
|
+
if (!result.acted && result.reason?.includes?.("node")) {
|
|
1405
|
+
const mainFrame = this.page.mainFrame();
|
|
1406
|
+
if (mainFrame !== frame) {
|
|
1407
|
+
const retry = await adapter.treeSelect(mainFrame, step.field || step.label, step.path || step.value, { timeout: step.timeout });
|
|
1408
|
+
if (retry.acted) {
|
|
1409
|
+
result = retry;
|
|
1410
|
+
result._dropdownFrame = "main";
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
this._lastActWasDropdown = true;
|
|
1416
|
+
return { type: "antTreeSelect", passed: result.acted && result.verified !== false, ...result };
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
async _antInput(step) {
|
|
1420
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1421
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1422
|
+
const target = step.placeholder
|
|
1423
|
+
? { placeholder: step.placeholder }
|
|
1424
|
+
: step.selector
|
|
1425
|
+
? { selector: step.selector }
|
|
1426
|
+
: { label: step.field || step.label };
|
|
1427
|
+
const result = await adapter.input(frame, target, step.value, { clear: step.clear });
|
|
1428
|
+
return { type: "antInput", passed: result.acted, ...result };
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
async _antRadio(step) {
|
|
1432
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1433
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1434
|
+
const result = await adapter.radio(frame, step.field || step.label, step.option || step.value);
|
|
1435
|
+
return { type: "antRadio", passed: result.acted && result.verified, ...result };
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
async _antCheckbox(step) {
|
|
1439
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1440
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1441
|
+
const result = await adapter.checkbox(frame, step.field || step.label, step.option, step.checked !== false);
|
|
1442
|
+
return { type: "antCheckbox", passed: result.acted && result.verified, ...result };
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
async _antSwitch(step) {
|
|
1446
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1447
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1448
|
+
const result = await adapter.switch(frame, step.field || step.label, step.on !== false);
|
|
1449
|
+
return { type: "antSwitch", passed: result.acted && result.verified, ...result };
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
async _antDatePicker(step) {
|
|
1453
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1454
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1455
|
+
const result = await adapter.datePicker(frame, step.field || step.label, step.value);
|
|
1456
|
+
this._lastActWasDropdown = true;
|
|
1457
|
+
return { type: "antDatePicker", passed: result.acted, ...result };
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
async _antCascader(step) {
|
|
1461
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1462
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1463
|
+
const result = await adapter.cascader(frame, step.field || step.label, step.path || step.value, { timeout: step.timeout });
|
|
1464
|
+
this._lastActWasDropdown = true;
|
|
1465
|
+
return { type: "antCascader", passed: result.acted, ...result };
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
async _antUpload(step) {
|
|
1469
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1470
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1471
|
+
const result = await adapter.upload(frame, step.field || step.label, step.filePath || step.value);
|
|
1472
|
+
return { type: "antUpload", passed: result.acted, ...result };
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
async _formFill(step) {
|
|
1476
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1477
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1478
|
+
const result = await adapter.formFill(frame, step.fields || [], { stepDelay: step.stepDelay });
|
|
1479
|
+
const allOk = result.summary.failed === 0;
|
|
1480
|
+
// 校验:可选地立即跑表单校验
|
|
1481
|
+
if (step.validateAfter) {
|
|
1482
|
+
const exp = createExpect({ frame, page: this.page });
|
|
1483
|
+
const validation = await exp.validation().toPass({ timeout: step.validationTimeout ?? 2000 });
|
|
1484
|
+
return {
|
|
1485
|
+
type: "formFill",
|
|
1486
|
+
passed: allOk && validation.passed,
|
|
1487
|
+
...result,
|
|
1488
|
+
validation,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
return { type: "formFill", passed: allOk, ...result };
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
async _closeAllDropdowns(step) {
|
|
1495
|
+
const { frame } = await this._getTargetFrame(step || {});
|
|
1496
|
+
const adapter = this._getAdapter();
|
|
1497
|
+
// 微前端场景:同时清理主 frame 和所有子 frame 的浮层(修复 2 配套)
|
|
1498
|
+
const result = await this._closeDropdownsAllFrames(frame);
|
|
1499
|
+
this._lastActWasDropdown = false;
|
|
1500
|
+
return { type: "closeAllDropdowns", passed: true, ...result };
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* 修复 2:跨 frame 浮层检测 — Ant 浮层可能渲染在子应用 iframe 内或父应用 document.body
|
|
1505
|
+
* 同时清理当前 frame + 主 frame + 所有可访问子 frame 的浮层
|
|
1506
|
+
*/
|
|
1507
|
+
async _closeDropdownsAllFrames(currentFrame) {
|
|
1508
|
+
const adapter = this._getAdapter();
|
|
1509
|
+
const mainFrame = this.page.mainFrame();
|
|
1510
|
+
const allFrames = this.page.frames();
|
|
1511
|
+
const closed = { perFrame: [], total: 0 };
|
|
1512
|
+
|
|
1513
|
+
// 优先清理当前 frame
|
|
1514
|
+
const framesToClose = new Set([currentFrame, mainFrame, ...allFrames]);
|
|
1515
|
+
for (const frame of framesToClose) {
|
|
1516
|
+
try {
|
|
1517
|
+
// 探活
|
|
1518
|
+
const alive = await frame.evaluate(() => true).catch(() => false);
|
|
1519
|
+
if (!alive) continue;
|
|
1520
|
+
const r = await adapter.closeAllDropdowns(frame);
|
|
1521
|
+
if (r?.closed) {
|
|
1522
|
+
closed.total += r.closed;
|
|
1523
|
+
closed.perFrame.push({ frameUrl: frame.url().slice(0, 100), closed: r.closed });
|
|
1524
|
+
}
|
|
1525
|
+
} catch {
|
|
1526
|
+
// 跨域 iframe 无法 evaluate,跳过
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
return closed;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* 通用 expect 断言:根据 step.assertion 路由到 expect.js 的不同断言方法
|
|
1534
|
+
*
|
|
1535
|
+
* 支持的 assertion:
|
|
1536
|
+
* - "locator.toBeVisible" / "toBeHidden" / "toBeEnabled" / "toHaveText" / "toHaveValue" / "toHaveCount" / "toHaveAttribute"
|
|
1537
|
+
* - "validation.toPass" / "validation.toHaveError"
|
|
1538
|
+
* - "url.toContain" / "url.toMatch"
|
|
1539
|
+
* - "api.toAllPass" / "api.toInclude" / "api.toHaveCount"
|
|
1540
|
+
*/
|
|
1541
|
+
async _expect(step) {
|
|
1542
|
+
const assertion = step.assertion;
|
|
1543
|
+
const opts = { timeout: step.timeout, interval: step.interval };
|
|
1544
|
+
|
|
1545
|
+
if (!assertion) throw new Error("expect step requires assertion field");
|
|
1546
|
+
|
|
1547
|
+
// locator.* 系列
|
|
1548
|
+
if (assertion.startsWith("locator.") || assertion.startsWith("toBe") || assertion.startsWith("toHave")) {
|
|
1549
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1550
|
+
const loc = expectLocator(frame, step.selector);
|
|
1551
|
+
const method = assertion.replace(/^locator\./, "");
|
|
1552
|
+
if (typeof loc[method] !== "function") {
|
|
1553
|
+
throw new Error(`Unknown locator assertion: ${method}`);
|
|
1554
|
+
}
|
|
1555
|
+
// toHaveText/toHaveValue/toHaveAttribute/toHaveCount 需要 expected 参数
|
|
1556
|
+
const expected = step.expected ?? step.value ?? step.text ?? step.count;
|
|
1557
|
+
const r = method === "toHaveAttribute"
|
|
1558
|
+
? await loc.toHaveAttribute(step.name, expected, opts)
|
|
1559
|
+
: (expected !== undefined ? await loc[method](expected, opts) : await loc[method](opts));
|
|
1560
|
+
return { type: "expect", ...r };
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// validation.*
|
|
1564
|
+
if (assertion.startsWith("validation.")) {
|
|
1565
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1566
|
+
const val = expectValidation(frame);
|
|
1567
|
+
const method = assertion.replace("validation.", "");
|
|
1568
|
+
const r = method === "toHaveError"
|
|
1569
|
+
? await val.toHaveError(step.field || step.message, opts)
|
|
1570
|
+
: await val.toPass(opts);
|
|
1571
|
+
return { type: "expect", ...r };
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// url.*
|
|
1575
|
+
if (assertion.startsWith("url.")) {
|
|
1576
|
+
const u = expectUrl(this.page);
|
|
1577
|
+
const method = assertion.replace("url.", "");
|
|
1578
|
+
const r = method === "toMatch"
|
|
1579
|
+
? await u.toMatch(step.regex || step.pattern, opts)
|
|
1580
|
+
: await u.toContain(step.value || step.contains, opts);
|
|
1581
|
+
return { type: "expect", ...r };
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// api.*
|
|
1585
|
+
if (assertion.startsWith("api.")) {
|
|
1586
|
+
const capture = step.captureResult || this._lastApiCapture;
|
|
1587
|
+
// 微前端:支持按 frame 范围过滤请求
|
|
1588
|
+
// scope: "all"(默认,全 page)/ "current-frame"(仅 _activeFrame 同源)
|
|
1589
|
+
let frameUrlContains = step.frameUrlContains;
|
|
1590
|
+
if (!frameUrlContains && step.scope === "current-frame" && this._activeFrame) {
|
|
1591
|
+
const url = this._activeFrame.url();
|
|
1592
|
+
// blob: URL(wujie 子应用)取冒号后面的部分;http(s): URL 取 origin
|
|
1593
|
+
if (url.startsWith("blob:")) {
|
|
1594
|
+
try { frameUrlContains = new URL(url.slice(5)).origin; } catch {}
|
|
1595
|
+
} else {
|
|
1596
|
+
try { frameUrlContains = new URL(url).origin; } catch {}
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
const a = expectApi(capture, { scope: step.scope || "all", frameUrlContains });
|
|
1600
|
+
const method = assertion.replace("api.", "");
|
|
1601
|
+
if (typeof a[method] !== "function") {
|
|
1602
|
+
throw new Error(`Unknown api assertion: ${method}`);
|
|
1603
|
+
}
|
|
1604
|
+
const r = method === "toInclude"
|
|
1605
|
+
? a.toInclude(step.urlPattern || step.pattern)
|
|
1606
|
+
: method === "toHaveCount"
|
|
1607
|
+
? a.toHaveCount(step.expected ?? step.count)
|
|
1608
|
+
: a[method]();
|
|
1609
|
+
return { type: "expect", ...r };
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
throw new Error(`Unknown expect assertion: ${assertion}`);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
async _navigate(step) {
|
|
1616
|
+
const start = Date.now();
|
|
1617
|
+
// 微前端/SSO 重定向链场景:用 domcontentloaded 避免 page 对象被销毁
|
|
1618
|
+
const waitUntil = step.waitUntil || (step.allowRedirects ? "domcontentloaded" : "networkidle2");
|
|
1619
|
+
try {
|
|
1620
|
+
await this.page.goto(step.url, {
|
|
1621
|
+
waitUntil,
|
|
1622
|
+
timeout: step.timeout || 30000,
|
|
1623
|
+
});
|
|
1624
|
+
} catch (err) {
|
|
1625
|
+
// 重定向链可能触发超时,但页面可能已加载
|
|
1626
|
+
if (err.name === "TimeoutError" && step.allowRedirects) {
|
|
1627
|
+
// 忽略超时,页面可能正在重定向中
|
|
1628
|
+
} else {
|
|
1629
|
+
throw err;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
// 重定向后等待页面稳定
|
|
1633
|
+
if (step.allowRedirects) {
|
|
1634
|
+
await new Promise((r) => setTimeout(r, step.redirectWait || 5000));
|
|
1635
|
+
}
|
|
1636
|
+
const title = await this.safeEval(() => document.title).catch(() => "");
|
|
1637
|
+
return {
|
|
1638
|
+
url: this.page.url(),
|
|
1639
|
+
title,
|
|
1640
|
+
loadTime: Date.now() - start,
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
async _screenshot(step, context) {
|
|
1645
|
+
const fs = await import("node:fs");
|
|
1646
|
+
const path = await import("node:path");
|
|
1647
|
+
const outputDir = context.outputDir || "/tmp";
|
|
1648
|
+
if (!fs.existsSync(outputDir)) {
|
|
1649
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
const filename = step.filename || `screenshot-${Date.now()}.png`;
|
|
1653
|
+
const filepath = path.join(outputDir, filename);
|
|
1654
|
+
|
|
1655
|
+
await this.page.screenshot({
|
|
1656
|
+
path: filepath,
|
|
1657
|
+
fullPage: step.fullPage !== false,
|
|
1658
|
+
...(step.clip ? { clip: step.clip } : {}),
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
const viewport = this.page.viewport();
|
|
1662
|
+
return { filepath, width: viewport.width, height: viewport.height };
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
async _compare(step, context) {
|
|
1666
|
+
const { compareDesign } = await import("./visual-compare.js");
|
|
1667
|
+
const fs = await import("node:fs");
|
|
1668
|
+
const path = await import("node:path");
|
|
1669
|
+
const outputDir = context.outputDir || "/tmp";
|
|
1670
|
+
|
|
1671
|
+
// 先截图
|
|
1672
|
+
if (!fs.existsSync(outputDir)) {
|
|
1673
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
1674
|
+
}
|
|
1675
|
+
const screenshotPath = path.join(outputDir, `compare-screenshot-${Date.now()}.png`);
|
|
1676
|
+
await this.page.screenshot({ path: screenshotPath, fullPage: true });
|
|
1677
|
+
|
|
1678
|
+
// 执行对比
|
|
1679
|
+
const result = await compareDesign(screenshotPath, step.designImagePath, {
|
|
1680
|
+
threshold: step.threshold,
|
|
1681
|
+
outputDir,
|
|
1682
|
+
regions: step.regions,
|
|
1683
|
+
page: this.page,
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
return result;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
async _click(step) {
|
|
1690
|
+
const target = this._activeFrame || this.page;
|
|
1691
|
+
const selector = this._resolveSelector(step.selector);
|
|
1692
|
+
|
|
1693
|
+
// iframe 内使用 evaluate 点击(坐标点击无法到达 iframe 内容)
|
|
1694
|
+
if (this._activeFrame) {
|
|
1695
|
+
const clicked = await this.safeEval((sel) => {
|
|
1696
|
+
const el = document.querySelector(sel);
|
|
1697
|
+
if (!el) return { found: false };
|
|
1698
|
+
el.click();
|
|
1699
|
+
return { found: true, tagName: el.tagName };
|
|
1700
|
+
}, selector);
|
|
1701
|
+
if (!clicked.found) {
|
|
1702
|
+
// 回退:模糊匹配
|
|
1703
|
+
const fuzzyResult = await this.safeEval((sel) => {
|
|
1704
|
+
const base = sel.replace(/\[class\*="([^"]+)"\]/, "$1").replace(/[.#]/, "");
|
|
1705
|
+
const el = [...document.querySelectorAll("*")].find(e =>
|
|
1706
|
+
(e.className || "").includes(base)
|
|
1707
|
+
);
|
|
1708
|
+
if (el) { el.click(); return { found: true, tagName: el.tagName, matchedClass: el.className } ; }
|
|
1709
|
+
return { found: false };
|
|
1710
|
+
}, selector);
|
|
1711
|
+
if (!fuzzyResult.found) throw new Error(`Element not found in frame: ${selector}`);
|
|
1712
|
+
return { clicked: true, selector, ...fuzzyResult, fuzzyMatch: true };
|
|
1713
|
+
}
|
|
1714
|
+
return { clicked: true, selector, ...clicked };
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// 主页面:标准 puppeteer 点击
|
|
1718
|
+
await target.waitForSelector(selector, { timeout: step.timeout || 5000 }).catch(() => {});
|
|
1719
|
+
if (step.waitForNavigation) {
|
|
1720
|
+
await Promise.all([
|
|
1721
|
+
this.page.waitForNavigation({ timeout: 10000 }),
|
|
1722
|
+
target.click(selector),
|
|
1723
|
+
]);
|
|
1724
|
+
} else {
|
|
1725
|
+
await target.click(selector);
|
|
1726
|
+
}
|
|
1727
|
+
const tagName = await target.$eval(selector, (el) => el.tagName).catch(() => "unknown");
|
|
1728
|
+
return { clicked: true, selector, tagName };
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
async _type(step) {
|
|
1732
|
+
const target = this._activeFrame || this.page;
|
|
1733
|
+
const selector = this._resolveSelector(step.selector);
|
|
1734
|
+
await target.waitForSelector(selector, { timeout: step.timeout || 5000 });
|
|
1735
|
+
|
|
1736
|
+
if (step.clear) {
|
|
1737
|
+
await target.click(selector, { clickCount: 3 });
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
await target.type(selector, step.text);
|
|
1741
|
+
const value = await target.$eval(selector, (el) => el.value).catch(() => "");
|
|
1742
|
+
return { typed: true, selector, value };
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
async _waitFor(step) {
|
|
1746
|
+
const timeout = step.timeout || 10000;
|
|
1747
|
+
|
|
1748
|
+
if (step.selector) {
|
|
1749
|
+
await this.page.waitForSelector(step.selector, { timeout });
|
|
1750
|
+
return { matched: true, type: "selector", value: step.selector };
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
if (step.url) {
|
|
1754
|
+
await this.page.waitForResponse(
|
|
1755
|
+
(r) => r.url().includes(step.url),
|
|
1756
|
+
{ timeout }
|
|
1757
|
+
);
|
|
1758
|
+
return { matched: true, type: "url", value: step.url };
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
throw new Error("waitFor requires either 'selector' or 'url'");
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
async _evaluate(step) {
|
|
1765
|
+
const result = await this.safeEval(step.expression);
|
|
1766
|
+
return { result };
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* 查找微前端 iframe(wujie/qiankun/single-spa 等)
|
|
1771
|
+
*/
|
|
1772
|
+
async _findFrame(step) {
|
|
1773
|
+
const frames = this.page.frames();
|
|
1774
|
+
let matched = null;
|
|
1775
|
+
let matchReason = "";
|
|
1776
|
+
|
|
1777
|
+
if (step.urlIncludes) {
|
|
1778
|
+
matched = frames.find((f) => f.url().includes(step.urlIncludes));
|
|
1779
|
+
if (matched) matchReason = `url contains "${step.urlIncludes}"`;
|
|
1780
|
+
} else if (step.urlRegex) {
|
|
1781
|
+
const re = new RegExp(step.urlRegex);
|
|
1782
|
+
matched = frames.find((f) => re.test(f.url()));
|
|
1783
|
+
if (matched) matchReason = `url matches /${step.urlRegex}/`;
|
|
1784
|
+
} else if (step.titleIncludes) {
|
|
1785
|
+
// 按页面标题查找 iframe
|
|
1786
|
+
for (const f of frames) {
|
|
1787
|
+
try {
|
|
1788
|
+
const title = await f.evaluate(() => document.title).catch(() => "");
|
|
1789
|
+
if (title && title.includes(step.titleIncludes)) {
|
|
1790
|
+
matched = f;
|
|
1791
|
+
matchReason = `title contains "${step.titleIncludes}"`;
|
|
1792
|
+
break;
|
|
1793
|
+
}
|
|
1794
|
+
} catch { /* context destroyed, skip */ }
|
|
1795
|
+
}
|
|
1796
|
+
} else if (step.contentIncludes) {
|
|
1797
|
+
// 按页面内容查找 iframe(查找包含指定文本的 iframe)
|
|
1798
|
+
for (const f of frames) {
|
|
1799
|
+
try {
|
|
1800
|
+
const has = await f.evaluate((text) =>
|
|
1801
|
+
document.body?.innerText?.includes(text) || false,
|
|
1802
|
+
step.contentIncludes
|
|
1803
|
+
).catch(() => false);
|
|
1804
|
+
if (has) {
|
|
1805
|
+
matched = f;
|
|
1806
|
+
matchReason = `body contains "${step.contentIncludes}"`;
|
|
1807
|
+
break;
|
|
1808
|
+
}
|
|
1809
|
+
} catch { /* context destroyed, skip */ }
|
|
1810
|
+
}
|
|
1811
|
+
} else if (step.frameIndex !== undefined) {
|
|
1812
|
+
matched = frames[step.frameIndex];
|
|
1813
|
+
if (matched) matchReason = `frameIndex ${step.frameIndex}`;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
if (!matched) {
|
|
1817
|
+
// 收集各 frame 的标题以便调试
|
|
1818
|
+
const frameInfos = [];
|
|
1819
|
+
for (const f of frames) {
|
|
1820
|
+
try {
|
|
1821
|
+
const title = await f.evaluate(() => document.title).catch(() => "");
|
|
1822
|
+
frameInfos.push({ url: f.url(), title: title || "(empty)" });
|
|
1823
|
+
} catch {
|
|
1824
|
+
frameInfos.push({ url: f.url(), title: "(error)" });
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
return {
|
|
1828
|
+
found: false,
|
|
1829
|
+
totalFrames: frames.length,
|
|
1830
|
+
frames: frameInfos,
|
|
1831
|
+
error: "No matching frame found",
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
this._activeFrame = matched;
|
|
1836
|
+
return {
|
|
1837
|
+
found: true,
|
|
1838
|
+
frameUrl: matched.url(),
|
|
1839
|
+
matchedBy: matchReason,
|
|
1840
|
+
totalFrames: frames.length,
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/**
|
|
1845
|
+
* 查找已打开的标签页(connect 模式,端口可变场景)
|
|
1846
|
+
*/
|
|
1847
|
+
async _findPage(step) {
|
|
1848
|
+
const pages = await this._safeGetPages();
|
|
1849
|
+
let matched = null;
|
|
1850
|
+
let matchReason = "";
|
|
1851
|
+
|
|
1852
|
+
if (step.urlIncludes) {
|
|
1853
|
+
matched = pages.find((p) => p.url().includes(step.urlIncludes));
|
|
1854
|
+
if (matched) matchReason = `url contains "${step.urlIncludes}"`;
|
|
1855
|
+
} else if (step.urlRegex) {
|
|
1856
|
+
const re = new RegExp(step.urlRegex);
|
|
1857
|
+
matched = pages.find((p) => re.test(p.url()));
|
|
1858
|
+
if (matched) matchReason = `url matches /${step.urlRegex}/`;
|
|
1859
|
+
} else if (step.titleIncludes) {
|
|
1860
|
+
for (const p of pages) {
|
|
1861
|
+
try {
|
|
1862
|
+
const title = await p.title();
|
|
1863
|
+
if (title && title.includes(step.titleIncludes)) {
|
|
1864
|
+
matched = p;
|
|
1865
|
+
matchReason = `title contains "${step.titleIncludes}"`;
|
|
1866
|
+
break;
|
|
1867
|
+
}
|
|
1868
|
+
} catch { /* page may be closed */ }
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
if (!matched) {
|
|
1873
|
+
const pageInfos = await Promise.all(
|
|
1874
|
+
pages.map(async (p) => {
|
|
1875
|
+
try {
|
|
1876
|
+
return { url: p.url(), title: (await p.title()) || "(empty)" };
|
|
1877
|
+
} catch {
|
|
1878
|
+
return { url: p.url(), title: "(error)" };
|
|
1879
|
+
}
|
|
1880
|
+
})
|
|
1881
|
+
);
|
|
1882
|
+
return {
|
|
1883
|
+
found: false,
|
|
1884
|
+
totalPages: pages.length,
|
|
1885
|
+
pages: pageInfos,
|
|
1886
|
+
error: "No matching page/tab found",
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
this.page = matched;
|
|
1891
|
+
this._activeFrame = null; // 重置 frame 上下文
|
|
1892
|
+
if (step.viewport) {
|
|
1893
|
+
await this.page.setViewport(step.viewport);
|
|
1894
|
+
}
|
|
1895
|
+
return {
|
|
1896
|
+
found: true,
|
|
1897
|
+
url: matched.url(),
|
|
1898
|
+
title: await matched.title().catch(() => ""),
|
|
1899
|
+
matchedBy: matchReason,
|
|
1900
|
+
totalPages: pages.length,
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* 等待登录完成(SSO 重定向场景)
|
|
1906
|
+
* 轮询检测登录页是否消失
|
|
1907
|
+
*/
|
|
1908
|
+
async _loginWait(step) {
|
|
1909
|
+
const timeout = step.timeout || 120000;
|
|
1910
|
+
const interval = step.interval || 3000;
|
|
1911
|
+
const loginSelector = step.loginSelector || 'input[type="password"]';
|
|
1912
|
+
const startTime = Date.now();
|
|
1913
|
+
|
|
1914
|
+
// 检测当前是否在登录页
|
|
1915
|
+
const isLoginPage = async () => {
|
|
1916
|
+
return this.safeEval((sel) => !!document.querySelector(sel), loginSelector).catch(() => false);
|
|
1917
|
+
};
|
|
1918
|
+
|
|
1919
|
+
let loggedIn = !(await isLoginPage());
|
|
1920
|
+
while (!loggedIn && Date.now() - startTime < timeout) {
|
|
1921
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
1922
|
+
loggedIn = !(await isLoginPage());
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
const elapsed = Date.now() - startTime;
|
|
1926
|
+
if (!loggedIn) {
|
|
1927
|
+
return { loggedIn: false, elapsed, error: "Login timeout" };
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// 登录后等待页面稳定
|
|
1931
|
+
await new Promise((r) => setTimeout(r, step.postLoginWait || 5000));
|
|
1932
|
+
return {
|
|
1933
|
+
loggedIn: true,
|
|
1934
|
+
elapsed,
|
|
1935
|
+
currentUrl: this.page.url(),
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
async _checkElement(step) {
|
|
1940
|
+
const selector = this._resolveSelector(step.selector);
|
|
1941
|
+
const expect = step.expect || {};
|
|
1942
|
+
|
|
1943
|
+
// 在 frame 内使用 safeEval,主页面也使用 safeEval 防止 context destroyed
|
|
1944
|
+
const info = await this.safeEval((sel) => {
|
|
1945
|
+
// 支持 CSS Modules 模糊匹配:[class*=xxx]
|
|
1946
|
+
const els = document.querySelectorAll(sel);
|
|
1947
|
+
return {
|
|
1948
|
+
count: els.length,
|
|
1949
|
+
visible: els.length > 0 && els[0].offsetParent !== null,
|
|
1950
|
+
texts: Array.from(els).slice(0, 5).map((el) =>
|
|
1951
|
+
el.textContent?.substring(0, 100) || ""
|
|
1952
|
+
),
|
|
1953
|
+
};
|
|
1954
|
+
}, selector);
|
|
1955
|
+
|
|
1956
|
+
let passed = true;
|
|
1957
|
+
const checks = {};
|
|
1958
|
+
|
|
1959
|
+
if (expect.visible !== undefined) {
|
|
1960
|
+
checks.visible = { expected: expect.visible, actual: info.visible };
|
|
1961
|
+
if (info.visible !== expect.visible) passed = false;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
if (expect.minCount !== undefined) {
|
|
1965
|
+
checks.minCount = { expected: expect.minCount, actual: info.count };
|
|
1966
|
+
if (info.count < expect.minCount) passed = false;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
if (expect.maxCount !== undefined) {
|
|
1970
|
+
checks.maxCount = { expected: expect.maxCount, actual: info.count };
|
|
1971
|
+
if (info.count > expect.maxCount) passed = false;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
if (expect.hasText !== undefined) {
|
|
1975
|
+
const allText = info.texts.join(" ");
|
|
1976
|
+
checks.hasText = { expected: expect.hasText, actual: allText };
|
|
1977
|
+
if (!allText.includes(expect.hasText)) passed = false;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
return {
|
|
1981
|
+
passed,
|
|
1982
|
+
selector,
|
|
1983
|
+
checks,
|
|
1984
|
+
elementCount: info.count,
|
|
1985
|
+
visible: info.visible,
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
/**
|
|
1990
|
+
* 解析选择器:自动处理 CSS Modules 哈希类名
|
|
1991
|
+
* 如果 selector 以 ~ 开头,转换为 [class*=xxx] 模糊匹配
|
|
1992
|
+
*/
|
|
1993
|
+
/**
|
|
1994
|
+
* AI 语义断言:截图并标记需要视觉审查
|
|
1995
|
+
* 执行器只负责截图,判断由 Agent 或 MCP 服务端 VLM 完成
|
|
1996
|
+
*/
|
|
1997
|
+
async _aiAssert(step, context) {
|
|
1998
|
+
const fs = await import("node:fs");
|
|
1999
|
+
const path = await import("node:path");
|
|
2000
|
+
const outputDir = context.outputDir || "/tmp";
|
|
2001
|
+
if (!fs.existsSync(outputDir)) {
|
|
2002
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
const filename = step.filename || `ai-assert-${Date.now()}.png`;
|
|
2006
|
+
const filepath = path.join(outputDir, filename);
|
|
2007
|
+
|
|
2008
|
+
await this.page.screenshot({
|
|
2009
|
+
path: filepath,
|
|
2010
|
+
fullPage: step.fullPage !== false,
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
return {
|
|
2014
|
+
screenshotPath: filepath,
|
|
2015
|
+
assertion: step.assertion,
|
|
2016
|
+
needsVisualReview: true,
|
|
2017
|
+
reviewHint: "请通过以下方式验证此截图:\n" +
|
|
2018
|
+
"1. 如果你能读取图片 → 直接查看截图文件判断是否满足断言\n" +
|
|
2019
|
+
"2. 如果 MCP 服务器可用 → 上传截图后调用 assert_page_screenshot 工具\n" +
|
|
2020
|
+
"3. 降级方案 → 使用 checkElement 步骤做 DOM 级替代验证",
|
|
2021
|
+
};
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
/**
|
|
2025
|
+
* 高层 runner:把"批量探索动态菜单 + 抓接口 + 出报告"打包成一个 step。
|
|
2026
|
+
* 当前仅支持 task='menu-api-scan',对应反馈中"129 路由全覆盖"场景。
|
|
2027
|
+
*
|
|
2028
|
+
* options:
|
|
2029
|
+
* task: "menu-api-scan"
|
|
2030
|
+
* menuApiPattern (string|RegExp 默认 /listUserViewMenus/) — 用作 _discoverMenus 的 lastApiCapture 过滤
|
|
2031
|
+
* menuLimit (number 默认 0=不限) — 仅探索前 N 条
|
|
2032
|
+
* menuPathPrefix (Array<string>) — 仅探索 path 开头匹配的菜单
|
|
2033
|
+
* menuFilter (Array<string>) — 仅探索 name/path 包含任一关键字的菜单
|
|
2034
|
+
* drawerTriggerSelector / drawerOpenSelector / menuScrollContainer / useNativeHover / maxRetryPerStep — 透传 _actMenuPath
|
|
2035
|
+
* apiCaptureOptions (object) — 透传 captureApi
|
|
2036
|
+
* continueOnFail (bool 默认 true)
|
|
2037
|
+
* snapshotEach (bool 默认 false) — 每条菜单后做轻量 observe
|
|
2038
|
+
* screenshotEach (bool 默认 false) — 失败时截图(依赖 context.outputDir)
|
|
2039
|
+
*/
|
|
2040
|
+
async runTask(step, context = {}) {
|
|
2041
|
+
const taskName = step.task || step.name || "menu-api-scan";
|
|
2042
|
+
if (taskName !== "menu-api-scan") {
|
|
2043
|
+
return { task: taskName, supported: false, error: `Unsupported task: ${taskName}` };
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
const menus = await this._discoverMenus(step, context);
|
|
2047
|
+
if (!menus.length) {
|
|
2048
|
+
return {
|
|
2049
|
+
task: taskName,
|
|
2050
|
+
discovered: 0,
|
|
2051
|
+
error: "No menus discovered. Run apiCapture targeting menuApiPattern first, or provide step.menus directly.",
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
const limit = Number.parseInt(step.menuLimit ?? 0, 10);
|
|
2056
|
+
const candidates = limit > 0 ? menus.slice(0, limit) : menus;
|
|
2057
|
+
const continueOnFail = step.continueOnFail !== false;
|
|
2058
|
+
const startedAt = Date.now();
|
|
2059
|
+
const results = [];
|
|
2060
|
+
const byOutcome = { clickFailed: 0, noApi: 0, apiOk: 0, apiFailed: 0 };
|
|
2061
|
+
|
|
2062
|
+
for (let i = 0; i < candidates.length; i += 1) {
|
|
2063
|
+
const menu = candidates[i];
|
|
2064
|
+
const itemStartedAt = Date.now();
|
|
2065
|
+
const item = {
|
|
2066
|
+
index: i,
|
|
2067
|
+
menu: { path: menu.path, name: menu.name, names: menu.names },
|
|
2068
|
+
outcome: null,
|
|
2069
|
+
};
|
|
2070
|
+
|
|
2071
|
+
// 1. 点菜单链路(走 act 顶层入口,让它先 _resolveObserveFrame 再分发到 _actMenuPath)
|
|
2072
|
+
const menuStep = {
|
|
2073
|
+
menuPath: menu.names,
|
|
2074
|
+
drawerTriggerSelector: step.drawerTriggerSelector,
|
|
2075
|
+
drawerOpenSelector: step.drawerOpenSelector,
|
|
2076
|
+
menuScrollContainer: step.menuScrollContainer,
|
|
2077
|
+
useNativeHover: step.useNativeHover,
|
|
2078
|
+
maxRetryPerStep: step.maxRetryPerStep,
|
|
2079
|
+
stepDelay: step.stepDelay,
|
|
2080
|
+
frameContentContains: step.frameContentContains,
|
|
2081
|
+
frameUrlContains: step.frameUrlContains,
|
|
2082
|
+
frameIndex: step.frameIndex,
|
|
2083
|
+
};
|
|
2084
|
+
let menuResult;
|
|
2085
|
+
try {
|
|
2086
|
+
menuResult = await this.act(menuStep);
|
|
2087
|
+
} catch (err) {
|
|
2088
|
+
menuResult = { acted: false, error: err.message };
|
|
2089
|
+
}
|
|
2090
|
+
item.menuResult = menuResult;
|
|
2091
|
+
|
|
2092
|
+
if (!menuResult || menuResult.acted === false) {
|
|
2093
|
+
item.outcome = "clickFailed";
|
|
2094
|
+
byOutcome.clickFailed += 1;
|
|
2095
|
+
if (step.screenshotEach) {
|
|
2096
|
+
try {
|
|
2097
|
+
const shot = await this._screenshot({ filename: `runTask-fail-${i}.png`, fullPage: true }, context);
|
|
2098
|
+
item.screenshot = shot;
|
|
2099
|
+
} catch {
|
|
2100
|
+
// ignore
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
results.push({ ...item, durationMs: Date.now() - itemStartedAt });
|
|
2104
|
+
if (!continueOnFail) break;
|
|
2105
|
+
continue;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
// 2. 抓接口
|
|
2109
|
+
const apiStep = {
|
|
2110
|
+
type: "apiCapture",
|
|
2111
|
+
...(step.apiCaptureOptions || {}),
|
|
2112
|
+
wait: step.apiCaptureOptions?.wait ?? 4000,
|
|
2113
|
+
};
|
|
2114
|
+
let apiResult;
|
|
2115
|
+
try {
|
|
2116
|
+
apiResult = await this.captureApi(apiStep);
|
|
2117
|
+
} catch (err) {
|
|
2118
|
+
apiResult = { error: err.message, summary: { total: 0, failed: 0 } };
|
|
2119
|
+
}
|
|
2120
|
+
item.api = apiResult;
|
|
2121
|
+
|
|
2122
|
+
const total = apiResult?.summary?.total ?? 0;
|
|
2123
|
+
const failed = apiResult?.summary?.failed ?? 0;
|
|
2124
|
+
if (total === 0) {
|
|
2125
|
+
item.outcome = "noApi";
|
|
2126
|
+
byOutcome.noApi += 1;
|
|
2127
|
+
} else if (failed > 0) {
|
|
2128
|
+
item.outcome = "apiFailed";
|
|
2129
|
+
byOutcome.apiFailed += 1;
|
|
2130
|
+
} else {
|
|
2131
|
+
item.outcome = "apiOk";
|
|
2132
|
+
byOutcome.apiOk += 1;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
if (step.snapshotEach) {
|
|
2136
|
+
try {
|
|
2137
|
+
item.snapshot = await this.observe({ headingsOnly: true });
|
|
2138
|
+
} catch {
|
|
2139
|
+
// ignore
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
results.push({ ...item, durationMs: Date.now() - itemStartedAt });
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
return {
|
|
2147
|
+
task: taskName,
|
|
2148
|
+
discovered: menus.length,
|
|
2149
|
+
attempted: results.length,
|
|
2150
|
+
durationMs: Date.now() - startedAt,
|
|
2151
|
+
byOutcome,
|
|
2152
|
+
results,
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
/**
|
|
2157
|
+
* 从 lastApiCapture 中找菜单接口响应(默认 /listUserViewMenus/),
|
|
2158
|
+
* 用 CDP getResponseBody 拿到 body 后递归拍平。
|
|
2159
|
+
* 兜底:如果 step.menus 已显式传入,直接用之。
|
|
2160
|
+
*/
|
|
2161
|
+
async _discoverMenus(step = {}, context = {}) {
|
|
2162
|
+
if (Array.isArray(step.menus) && step.menus.length) {
|
|
2163
|
+
return this._flattenMenuTree(step.menus, step.menuPathPrefix, step.menuFilter);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
const lastCapture = step.captureResult || context.lastApiCapture;
|
|
2167
|
+
if (!lastCapture?.requests?.length) return [];
|
|
2168
|
+
|
|
2169
|
+
const patternRaw = step.menuApiPattern ?? "listUserViewMenus";
|
|
2170
|
+
const pattern = patternRaw instanceof RegExp ? patternRaw : new RegExp(patternRaw);
|
|
2171
|
+
const candidate = lastCapture.requests.find((r) => pattern.test(r.url || ""));
|
|
2172
|
+
if (!candidate) return [];
|
|
2173
|
+
|
|
2174
|
+
let tree = candidate.responseBody;
|
|
2175
|
+
if (typeof tree === "string") {
|
|
2176
|
+
try { tree = JSON.parse(tree); } catch { return []; }
|
|
2177
|
+
}
|
|
2178
|
+
// 常见包装:{ data: [...] } / { result: { data: [...] } }
|
|
2179
|
+
const list =
|
|
2180
|
+
(Array.isArray(tree) && tree) ||
|
|
2181
|
+
tree?.data ||
|
|
2182
|
+
tree?.result?.data ||
|
|
2183
|
+
tree?.result ||
|
|
2184
|
+
[];
|
|
2185
|
+
return this._flattenMenuTree(list, step.menuPathPrefix, step.menuFilter);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/**
|
|
2189
|
+
* 把菜单树拍平成可点击的链路:
|
|
2190
|
+
* { path, name, names: [一级名, 二级名, ...], depth }
|
|
2191
|
+
* children 字段名兼容 children/subMenus/sub。
|
|
2192
|
+
*/
|
|
2193
|
+
_flattenMenuTree(tree, pathPrefix, keywords) {
|
|
2194
|
+
const out = [];
|
|
2195
|
+
const prefixes = Array.isArray(pathPrefix) ? pathPrefix : pathPrefix ? [pathPrefix] : null;
|
|
2196
|
+
const kws = Array.isArray(keywords) ? keywords : keywords ? [keywords] : null;
|
|
2197
|
+
|
|
2198
|
+
const visit = (nodes, parentNames) => {
|
|
2199
|
+
if (!Array.isArray(nodes)) return;
|
|
2200
|
+
for (const node of nodes) {
|
|
2201
|
+
if (!node) continue;
|
|
2202
|
+
const name = node.name || node.title || node.label || node.menuName;
|
|
2203
|
+
const path = node.path || node.url || node.route;
|
|
2204
|
+
const names = name ? [...parentNames, name] : parentNames.slice();
|
|
2205
|
+
const children = node.children || node.subMenus || node.sub || node.childs;
|
|
2206
|
+
|
|
2207
|
+
// 叶子节点(无 children 或 children 空)才有意义点击触发请求
|
|
2208
|
+
const isLeaf = !Array.isArray(children) || children.length === 0;
|
|
2209
|
+
if (isLeaf && name && path) {
|
|
2210
|
+
let keep = true;
|
|
2211
|
+
if (prefixes && !prefixes.some((p) => path.startsWith(p))) keep = false;
|
|
2212
|
+
if (keep && kws && !kws.some((k) => name.includes(k) || path.includes(k))) keep = false;
|
|
2213
|
+
if (keep) out.push({ path, name, names, depth: names.length });
|
|
2214
|
+
} else if (Array.isArray(children) && children.length) {
|
|
2215
|
+
visit(children, names);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
};
|
|
2219
|
+
visit(tree, []);
|
|
2220
|
+
return out;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
_resolveSelector(selector) {
|
|
2224
|
+
if (!selector) return selector;
|
|
2225
|
+
// ~.editKnowledge → [class*="editKnowledge"]
|
|
2226
|
+
if (selector.startsWith("~.")) {
|
|
2227
|
+
const base = selector.slice(2);
|
|
2228
|
+
return `[class*="${base}"]`;
|
|
2229
|
+
}
|
|
2230
|
+
// ~#editKnowledge → [id*="editKnowledge"]
|
|
2231
|
+
if (selector.startsWith("~#")) {
|
|
2232
|
+
const base = selector.slice(2);
|
|
2233
|
+
return `[id*="${base}"]`;
|
|
2234
|
+
}
|
|
2235
|
+
return selector;
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
/**
|
|
2239
|
+
* 关闭浏览器
|
|
2240
|
+
* connect 模式: disconnect(不关闭用户浏览器)
|
|
2241
|
+
* launch 模式: close(关闭我们启动的浏览器)
|
|
2242
|
+
*/
|
|
2243
|
+
async close() {
|
|
2244
|
+
if (!this.browser) return;
|
|
2245
|
+
try {
|
|
2246
|
+
if (this.ownedBrowser) {
|
|
2247
|
+
await this.browser.close();
|
|
2248
|
+
} else {
|
|
2249
|
+
this.browser.disconnect();
|
|
2250
|
+
}
|
|
2251
|
+
} catch {
|
|
2252
|
+
// ignore close errors
|
|
2253
|
+
}
|
|
2254
|
+
this.browser = null;
|
|
2255
|
+
this.page = null;
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
/**
|
|
2259
|
+
* 执行测试用例:支持 dataset 参数化、变量替换
|
|
2260
|
+
* @param {object} testCase - 用例配置
|
|
2261
|
+
* @param {object} context - 执行上下文
|
|
2262
|
+
* @returns {Promise<object>} 用例执行结果摘要
|
|
2263
|
+
*/
|
|
2264
|
+
async executeTestCase(testCase, context = {}) {
|
|
2265
|
+
const startTime = Date.now();
|
|
2266
|
+
const results = [];
|
|
2267
|
+
const dataset = testCase.dataset || [{ __row: 0 }];
|
|
2268
|
+
|
|
2269
|
+
// setup 阶段:frame 预定位
|
|
2270
|
+
if (testCase.setup?.findFrame && !this._activeFrame) {
|
|
2271
|
+
const resolved = await this._resolveObserveFrame(testCase.setup.findFrame);
|
|
2272
|
+
this._activeFrame = resolved.frame;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
for (let dataRowIndex = 0; dataRowIndex < dataset.length; dataRowIndex++) {
|
|
2276
|
+
const dataRow = dataset[dataRowIndex];
|
|
2277
|
+
const rowStartTime = Date.now();
|
|
2278
|
+
const rowResults = [];
|
|
2279
|
+
let allPassed = true;
|
|
2280
|
+
|
|
2281
|
+
for (let stepIndex = 0; stepIndex < testCase.steps.length; stepIndex++) {
|
|
2282
|
+
const rawStep = testCase.steps[stepIndex];
|
|
2283
|
+
// 变量替换:{{key}} → dataRow[key]
|
|
2284
|
+
const step = this._substituteVariables(rawStep, dataRow);
|
|
2285
|
+
|
|
2286
|
+
// 微前端 frame 探活召回:每次 step 执行前确认 _activeFrame 仍然有效
|
|
2287
|
+
// 如果失效(子应用卸载/路由切换),尝试用 setup.findFrame 重新定位
|
|
2288
|
+
if (this._activeFrame) {
|
|
2289
|
+
const alive = await this._activeFrame.evaluate(() => true).catch(() => false);
|
|
2290
|
+
if (!alive) {
|
|
2291
|
+
this._activeFrame = null;
|
|
2292
|
+
if (testCase.setup?.findFrame) {
|
|
2293
|
+
const recovered = await this._resolveObserveFrame(testCase.setup.findFrame).catch(() => null);
|
|
2294
|
+
if (recovered?.frame) {
|
|
2295
|
+
this._activeFrame = recovered.frame;
|
|
2296
|
+
rowResults.push({
|
|
2297
|
+
type: "frameRecover",
|
|
2298
|
+
status: "PASS",
|
|
2299
|
+
duration: 0,
|
|
2300
|
+
stepIndex,
|
|
2301
|
+
dataRowIndex,
|
|
2302
|
+
matchedBy: recovered.matchedBy,
|
|
2303
|
+
reason: "auto-recovered from frame loss",
|
|
2304
|
+
});
|
|
2305
|
+
results.push(rowResults[rowResults.length - 1]);
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
const stepResult = await this.executeStep(step, context);
|
|
2312
|
+
|
|
2313
|
+
rowResults.push({
|
|
2314
|
+
...stepResult,
|
|
2315
|
+
stepIndex,
|
|
2316
|
+
dataRowIndex,
|
|
2317
|
+
dataRow: Object.fromEntries(Object.entries(dataRow).map(([k]) => [k, String(dataRow[k]).slice(0, 80)])),
|
|
2318
|
+
});
|
|
2319
|
+
results.push(rowResults[rowResults.length - 1]);
|
|
2320
|
+
|
|
2321
|
+
if (stepResult.status === "FAIL") {
|
|
2322
|
+
allPassed = false;
|
|
2323
|
+
const recovery = testCase.recovery || {};
|
|
2324
|
+
const maxRetries = recovery.maxRetriesPerStep ?? 0;
|
|
2325
|
+
for (let retry = 0; retry < maxRetries; retry++) {
|
|
2326
|
+
const retryResult = await this.executeStep(step, context);
|
|
2327
|
+
rowResults.push({
|
|
2328
|
+
...retryResult,
|
|
2329
|
+
stepIndex,
|
|
2330
|
+
dataRowIndex,
|
|
2331
|
+
retry: retry + 1,
|
|
2332
|
+
});
|
|
2333
|
+
results.push(rowResults[rowResults.length - 1]);
|
|
2334
|
+
if (retryResult.status === "PASS") {
|
|
2335
|
+
allPassed = true;
|
|
2336
|
+
break;
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
if (!allPassed && step.continueOnFail === false) break;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
// frame 切换:如果 step 设置了新 frame,后续步骤默认走新 frame
|
|
2343
|
+
if (step.findFrame || step.frameContentContains || step.frameUrlContains) {
|
|
2344
|
+
const resolved = await this._resolveObserveFrame(step);
|
|
2345
|
+
this._activeFrame = resolved.frame;
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
rowResults.duration = Date.now() - rowStartTime;
|
|
2350
|
+
rowResults.passed = allPassed;
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
const duration = Date.now() - startTime;
|
|
2354
|
+
const passed = results.filter((r) => r.status === "PASS").length;
|
|
2355
|
+
const failed = results.filter((r) => r.status === "FAIL").length;
|
|
2356
|
+
|
|
2357
|
+
return {
|
|
2358
|
+
title: testCase.title,
|
|
2359
|
+
datasetSize: dataset.length,
|
|
2360
|
+
totalSteps: results.length,
|
|
2361
|
+
passedSteps: passed,
|
|
2362
|
+
failedSteps: failed,
|
|
2363
|
+
passed: failed === 0,
|
|
2364
|
+
duration,
|
|
2365
|
+
results,
|
|
2366
|
+
summary: {
|
|
2367
|
+
perDataset: results.reduce((acc, r) => {
|
|
2368
|
+
const key = r.dataRowIndex;
|
|
2369
|
+
if (!acc[key]) acc[key] = { passed: 0, failed: 0, duration: 0 };
|
|
2370
|
+
if (r.status === "PASS") acc[key].passed++;
|
|
2371
|
+
else acc[key].failed++;
|
|
2372
|
+
acc[key].duration += r.duration;
|
|
2373
|
+
return acc;
|
|
2374
|
+
}, {}),
|
|
2375
|
+
},
|
|
2376
|
+
};
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
/**
|
|
2380
|
+
* 递归替换对象中的 {{varName}} 变量
|
|
2381
|
+
*/
|
|
2382
|
+
_substituteVariables(obj, dataRow) {
|
|
2383
|
+
if (typeof obj === "string") {
|
|
2384
|
+
return obj.replace(/\{\{([^}]+)\}\}/g, (_, key) => {
|
|
2385
|
+
const keys = key.split(".");
|
|
2386
|
+
let val = dataRow;
|
|
2387
|
+
for (const k of keys) val = val?.[k];
|
|
2388
|
+
return val !== undefined ? (typeof val === "string" ? val : String(val)) : "";
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
if (Array.isArray(obj)) {
|
|
2392
|
+
return obj.map((item) => this._substituteVariables(item, dataRow));
|
|
2393
|
+
}
|
|
2394
|
+
if (obj && typeof obj === "object") {
|
|
2395
|
+
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, this._substituteVariables(v, dataRow)]));
|
|
2396
|
+
}
|
|
2397
|
+
return obj;
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
/**
|
|
2402
|
+
* 动态加载 puppeteer-core,从共享缓存目录加载,缺失时自动安装
|
|
2403
|
+
*/
|
|
2404
|
+
async function loadPuppeteer() {
|
|
2405
|
+
const { ensureCoreDeps, loadFromShared } = await import("./shared-deps.js");
|
|
2406
|
+
|
|
2407
|
+
// 1. 尝试从项目本地 node_modules 加载(开发环境或已手动安装)
|
|
2408
|
+
try {
|
|
2409
|
+
const mod = await import("puppeteer-core");
|
|
2410
|
+
return mod.default || mod;
|
|
2411
|
+
} catch {
|
|
2412
|
+
// 本地未安装
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
// 2. observe/act/assert 主链路只需要 puppeteer-core
|
|
2416
|
+
await ensureCoreDeps();
|
|
2417
|
+
|
|
2418
|
+
try {
|
|
2419
|
+
return loadFromShared("puppeteer-core");
|
|
2420
|
+
} catch (err) {
|
|
2421
|
+
const error = new Error(`puppeteer-core 加载失败:${err.message}`);
|
|
2422
|
+
error.code = "DEPENDENCY_MISSING";
|
|
2423
|
+
throw error;
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
export default BrowserEngine;
|