@szc-ft/mcp-szcd-client 0.29.0 → 0.31.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/opencode-extension/skills/local-browser-test/SKILL.md +253 -8
- package/opencode-extension/skills/local-browser-test/decrypt-creds.mjs +210 -0
- package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +701 -17
- package/opencode-extension/skills/local-browser-test/lib/cdp-browser-config.js +115 -0
- package/opencode-extension/skills/local-browser-test/lib/decrypt-passwords.js +210 -0
- package/opencode-extension/skills/local-browser-test/lib/shared-deps.js +2 -2
- package/opencode-extension/skills/local-browser-test/local-browser-executor.js +50 -3
- package/package.json +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +253 -8
- package/qwen-extension/skills/local-browser-test/decrypt-creds.mjs +210 -0
- package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +701 -17
- package/qwen-extension/skills/local-browser-test/lib/cdp-browser-config.js +115 -0
- package/qwen-extension/skills/local-browser-test/lib/decrypt-passwords.js +210 -0
- package/qwen-extension/skills/local-browser-test/lib/shared-deps.js +2 -2
- package/qwen-extension/skills/local-browser-test/local-browser-executor.js +50 -3
- package/standard-skill/local-browser-test/SKILL.md +253 -8
- package/standard-skill/local-browser-test/decrypt-creds.mjs +210 -0
- package/standard-skill/local-browser-test/lib/browser-engine.js +701 -17
- package/standard-skill/local-browser-test/lib/cdp-browser-config.js +115 -0
- package/standard-skill/local-browser-test/lib/decrypt-passwords.js +210 -0
- package/standard-skill/local-browser-test/lib/shared-deps.js +2 -2
- package/standard-skill/local-browser-test/local-browser-executor.js +50 -3
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
import { findChrome, getChromeInstallGuide } from "./chrome-finder.js";
|
|
15
15
|
import { AntAdapter } from "./ant-adapter.js";
|
|
16
16
|
import { createExpect, locator as expectLocator, validation as expectValidation, url as expectUrl, api as expectApi } from "./expect.js";
|
|
17
|
+
import { getBrowserConfigFromProcess } from "./cdp-browser-config.js";
|
|
18
|
+
import { decryptAllPasswords } from "./decrypt-passwords.js";
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync, statSync } from 'fs';
|
|
20
|
+
import { join, dirname } from 'path';
|
|
21
|
+
import { homedir } from 'os';
|
|
22
|
+
import { fileURLToPath } from 'url';
|
|
17
23
|
|
|
18
24
|
export class BrowserEngine {
|
|
19
25
|
constructor() {
|
|
@@ -25,6 +31,8 @@ export class BrowserEngine {
|
|
|
25
31
|
this._adapter = null; // 懒加载 AntAdapter
|
|
26
32
|
this._lastActWasDropdown = false; // 用于 act 入口自动 closeAllDropdowns
|
|
27
33
|
this._wujieContext = null; // wujie 微前端模式 { type, frameCount, wujieApp? }
|
|
34
|
+
this._credentials = null; // 解密后的凭据 [{ url, username, password }],init() 时自动填充
|
|
35
|
+
this._cdpScripts = this._initCdpScripts(); // 内置通用 cdpScript
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
/**
|
|
@@ -81,27 +89,32 @@ export class BrowserEngine {
|
|
|
81
89
|
const frames = this.page.frames();
|
|
82
90
|
const frameCount = frames.length;
|
|
83
91
|
const mainFrame = this.page.mainFrame();
|
|
92
|
+
// JSON 输出返回完整文本不截断;stderr 日志仅截取前 200 字符预览
|
|
93
|
+
const LOG_PREVIEW_LEN = 200;
|
|
84
94
|
|
|
85
95
|
// 检查主文档中是否有 <wujie-app> 自定义元素(WebComponent 模式标志)
|
|
86
96
|
const wujieInfo = await mainFrame.evaluate(() => {
|
|
87
97
|
const app = document.querySelector('wujie-app');
|
|
88
98
|
if (!app) return null;
|
|
89
99
|
const sr = app.shadowRoot;
|
|
100
|
+
const body = sr?.querySelector('body');
|
|
101
|
+
const fullText = body?.innerText || '';
|
|
90
102
|
return {
|
|
91
103
|
tagName: app.tagName,
|
|
92
104
|
className: app.className,
|
|
93
105
|
hasShadowRoot: !!sr,
|
|
94
106
|
shadowChildren: sr?.children?.length || 0,
|
|
95
|
-
shadowBodyText:
|
|
107
|
+
shadowBodyText: fullText,
|
|
108
|
+
shadowBodyTextLength: fullText.length,
|
|
96
109
|
};
|
|
97
110
|
}).catch(() => null);
|
|
98
111
|
|
|
99
112
|
if (wujieInfo) {
|
|
100
113
|
const ctx = { type: 'webcomponent', frameCount, wujieApp: wujieInfo };
|
|
101
114
|
process.stderr.write(`[wujie] WebComponent 模式: <${wujieInfo.tagName} class="${wujieInfo.className}"> shadowRoot=${wujieInfo.hasShadowRoot} children=${wujieInfo.shadowChildren} frames=${frameCount}\n`);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
115
|
+
// stderr 只输出预览(不刷屏),JSON 中返回全文
|
|
116
|
+
const preview = wujieInfo.shadowBodyText?.substring(0, LOG_PREVIEW_LEN) || '';
|
|
117
|
+
process.stderr.write(`[wujie] shadow body (${wujieInfo.shadowBodyTextLength} chars): "${preview}${wujieInfo.shadowBodyTextLength > LOG_PREVIEW_LEN ? '...' : ''}"\n`);
|
|
105
118
|
// 列出所有 frame 供排障
|
|
106
119
|
for (let i = 0; i < frames.length; i++) {
|
|
107
120
|
process.stderr.write(`[wujie] frame[${i}]: ${frames[i].url().substring(0, 100)}\n`);
|
|
@@ -227,15 +240,89 @@ export class BrowserEngine {
|
|
|
227
240
|
// 微前端模式检测
|
|
228
241
|
this._wujieContext = await this._detectWujieMode();
|
|
229
242
|
|
|
243
|
+
// 自动解密凭据(从浏览器密码管理器)
|
|
244
|
+
this._credentials = await this._decryptCredentials();
|
|
245
|
+
|
|
230
246
|
return {
|
|
231
247
|
mode: "connect",
|
|
232
248
|
cdpUrl,
|
|
233
249
|
currentUrl: this.page?.url() || null,
|
|
234
250
|
totalPages: pages.length,
|
|
235
251
|
allPageUrls: pages.map((p) => p.url()),
|
|
252
|
+
credentials: this._credentials, // 解密后的凭据
|
|
236
253
|
};
|
|
237
254
|
}
|
|
238
255
|
|
|
256
|
+
/**
|
|
257
|
+
* 自动解密浏览器保存的凭据(connect 模式初始化时调用)
|
|
258
|
+
* 结果缓存到 ~/.szcd-mcp/deps/decrypted-creds.json,避免重复解密
|
|
259
|
+
* @returns {Array|null} [{ url, username, password }] 或 null
|
|
260
|
+
*/
|
|
261
|
+
async _decryptCredentials() {
|
|
262
|
+
const CACHE_DIR = join(homedir(), '.szcd-mcp', 'deps');
|
|
263
|
+
const CACHE_FILE = join(CACHE_DIR, 'decrypted-creds.json');
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
// 1. 检测 user-data-dir
|
|
267
|
+
const config = getBrowserConfigFromProcess();
|
|
268
|
+
if (!config?.userDataDir) return null;
|
|
269
|
+
const userDataDir = config.userDataDir;
|
|
270
|
+
|
|
271
|
+
// 2. 检查缓存(Login Data 未变 + 24 小时内)
|
|
272
|
+
const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 小时
|
|
273
|
+
if (existsSync(CACHE_FILE)) {
|
|
274
|
+
try {
|
|
275
|
+
const cache = JSON.parse(readFileSync(CACHE_FILE, 'utf8'));
|
|
276
|
+
const cacheKey = `${userDataDir}::%%`;
|
|
277
|
+
if (cache[cacheKey]) {
|
|
278
|
+
const entry = cache[cacheKey];
|
|
279
|
+
const age = Date.now() - entry.cachedAt;
|
|
280
|
+
// 检查 Login Data 是否被修改
|
|
281
|
+
const loginDataPath = join(userDataDir, 'Default', 'Login Data');
|
|
282
|
+
if (existsSync(loginDataPath)) {
|
|
283
|
+
const mtime = statSync(loginDataPath).mtimeMs;
|
|
284
|
+
if (mtime > entry.cachedAt) {
|
|
285
|
+
if (options.verbose) process.stderr.write('[browser-engine] Login Data 已更新,缓存过期\n');
|
|
286
|
+
} else if (age > CACHE_MAX_AGE_MS) {
|
|
287
|
+
process.stderr.write(`[browser-engine] 缓存已过期 (${Math.round(age / 3600000)}h),重新解密\n`);
|
|
288
|
+
} else {
|
|
289
|
+
process.stderr.write(`[browser-engine] ✓ 凭据缓存命中 (${entry.credentials.length} 条, ${Math.round(age / 60000)}min 前)\n`);
|
|
290
|
+
return entry.credentials;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} catch { /* 缓存读取失败,继续解密 */ }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 3. 解密
|
|
298
|
+
process.stderr.write('[browser-engine] 解密浏览器凭据...\n');
|
|
299
|
+
const credentials = await decryptAllPasswords({
|
|
300
|
+
userDataDir,
|
|
301
|
+
filter: '%',
|
|
302
|
+
verbose: false,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
if (credentials.length === 0) return null;
|
|
306
|
+
|
|
307
|
+
// 4. 写入缓存
|
|
308
|
+
try {
|
|
309
|
+
let cache = {};
|
|
310
|
+
if (existsSync(CACHE_FILE)) {
|
|
311
|
+
try { cache = JSON.parse(readFileSync(CACHE_FILE, 'utf8')); } catch {}
|
|
312
|
+
}
|
|
313
|
+
const cacheKey = `${userDataDir}::%%`;
|
|
314
|
+
cache[cacheKey] = { cachedAt: Date.now(), userDataDir, credentials };
|
|
315
|
+
writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2), 'utf8');
|
|
316
|
+
process.stderr.write(`[browser-engine] ✓ 已缓存 ${credentials.length} 条凭据\n`);
|
|
317
|
+
} catch { /* 缓存写入失败不致命 */ }
|
|
318
|
+
|
|
319
|
+
return credentials;
|
|
320
|
+
} catch (e) {
|
|
321
|
+
process.stderr.write(`[browser-engine] 凭据解密失败: ${e.message}\n`);
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
239
326
|
/**
|
|
240
327
|
* 模式 B:启动新的 Chrome
|
|
241
328
|
*/
|
|
@@ -443,17 +530,36 @@ export class BrowserEngine {
|
|
|
443
530
|
|
|
444
531
|
function findBySelector(selector) {
|
|
445
532
|
if (!selector) return null;
|
|
533
|
+
// text== 精确匹配(Playwright 兼容):只匹配 innerText 完全相等的元素
|
|
534
|
+
if (selector.startsWith("text==")) {
|
|
535
|
+
const expected = selector.slice(6).trim();
|
|
536
|
+
const filter = (el) => isVisibleEnabled(el);
|
|
537
|
+
const interactive = interactiveElements().filter(filter);
|
|
538
|
+
let hit = interactive.find((el) => getText(el) === expected || getName(el, getText(el)) === expected);
|
|
539
|
+
if (hit) return hit;
|
|
540
|
+
const all = Array.from(document.querySelectorAll("*")).filter(filter);
|
|
541
|
+
return all.find((el) => getText(el) === expected) || null;
|
|
542
|
+
}
|
|
543
|
+
// text= 模糊匹配:优先选 innerText 最短的元素(离目标文字最近的叶子节点)
|
|
544
|
+
// 避免命中包含目标文字的大容器(如 drawer 整体 div)
|
|
446
545
|
if (selector.startsWith("text=")) {
|
|
447
546
|
const expected = selector.slice(5).trim();
|
|
448
|
-
// 默认仅匹配 visible+enabled 元素,避免命中 ant-select 的隐藏 input、收起菜单里的 li
|
|
449
|
-
// 需要包含隐藏元素时显式 includeHidden=true
|
|
450
547
|
const includeHidden = actOptions.includeHidden === true;
|
|
451
548
|
const filter = (el) => includeHidden || isVisibleEnabled(el);
|
|
452
549
|
const interactive = interactiveElements().filter(filter);
|
|
453
|
-
let
|
|
454
|
-
if (
|
|
550
|
+
let hits = interactive.filter((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected));
|
|
551
|
+
if (hits.length > 0) {
|
|
552
|
+
// 按 innerText 长度升序:最短的最可能是目标元素本身而非父容器
|
|
553
|
+
hits.sort((a, b) => getText(a).length - getText(b).length);
|
|
554
|
+
return hits[0];
|
|
555
|
+
}
|
|
455
556
|
const all = Array.from(document.querySelectorAll("*")).filter(filter);
|
|
456
|
-
|
|
557
|
+
hits = all.filter((el) => getText(el).includes(expected));
|
|
558
|
+
if (hits.length > 0) {
|
|
559
|
+
hits.sort((a, b) => getText(a).length - getText(b).length);
|
|
560
|
+
return hits[0];
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
457
563
|
}
|
|
458
564
|
if (selector.startsWith("role=")) {
|
|
459
565
|
const match = selector.match(/^role=([^\[]+)(?:\[name="([^"]+)"\])?/);
|
|
@@ -1249,8 +1355,23 @@ export class BrowserEngine {
|
|
|
1249
1355
|
|
|
1250
1356
|
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
|
1251
1357
|
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
1252
|
-
|
|
1253
|
-
|
|
1358
|
+
|
|
1359
|
+
// 1. 先收集 portal 弹窗/抽屉中的元素(排在前面,避免 text= 匹配到外层容器)
|
|
1360
|
+
const portalSelectors = '.ant-drawer:not(.ant-drawer-closed), .ant-drawer-open .ant-drawer-content-wrapper, .ant-modal-wrap:not([style*="display: none"]), .ant-dropdown:not(.ant-dropdown-hidden)';
|
|
1361
|
+
const portalEls = [];
|
|
1362
|
+
document.querySelectorAll(portalSelectors).forEach(portal => {
|
|
1363
|
+
portal.querySelectorAll('a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*="btn"],[class*="button"],[class*="ant-btn"]').forEach(el => {
|
|
1364
|
+
if (isCandidate(el)) portalEls.push(el);
|
|
1365
|
+
});
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
// 2. 再收集 body 中的元素,但要排除已在 portal 中的(避免重复)
|
|
1369
|
+
const portalSet = new Set(portalEls);
|
|
1370
|
+
const bodyEls = 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']"))
|
|
1371
|
+
.filter(el => isCandidate(el) && !portalSet.has(el));
|
|
1372
|
+
|
|
1373
|
+
// portal 元素排前面(优先级更高),body 元素排后面
|
|
1374
|
+
return portalEls.concat(bodyEls)
|
|
1254
1375
|
.slice(0, limit)
|
|
1255
1376
|
.map((el, idx) => {
|
|
1256
1377
|
const rect = el.getBoundingClientRect();
|
|
@@ -1321,6 +1442,12 @@ export class BrowserEngine {
|
|
|
1321
1442
|
case "antUpload": return this._antUpload(step);
|
|
1322
1443
|
case "formFill": return this._formFill(step);
|
|
1323
1444
|
case "closeAllDropdowns": return this._closeAllDropdowns(step);
|
|
1445
|
+
// ===== cdpScript / rawScript =====
|
|
1446
|
+
case "apiCall": case "api-call": return this._apiCall(step);
|
|
1447
|
+
case "switchPage": case "switch-page": return this._switchPage(step);
|
|
1448
|
+
case "registerCdpScript": case "register-cdp-script": return this._registerCdpScript(step);
|
|
1449
|
+
case "cdpScript": case "cdp-script": return this._cdpScript(step);
|
|
1450
|
+
case "rawScript": case "raw-script": return this._rawScript(step);
|
|
1324
1451
|
// ===== 语义断言层 =====
|
|
1325
1452
|
case "expect": return this._expect(step);
|
|
1326
1453
|
default:
|
|
@@ -2255,6 +2382,307 @@ export class BrowserEngine {
|
|
|
2255
2382
|
this.page = null;
|
|
2256
2383
|
}
|
|
2257
2384
|
|
|
2385
|
+
/**
|
|
2386
|
+
* 通用 cdpScript 注册——封装跨项目复用的 UI 交互模式
|
|
2387
|
+
* 业务特定逻辑(审核/变更/下线)由 LLM 用 rawScript 动态生成
|
|
2388
|
+
*/
|
|
2389
|
+
_initCdpScripts() {
|
|
2390
|
+
return {
|
|
2391
|
+
'drawer-action': async (page, frame, params) => {
|
|
2392
|
+
const { action, radioIndex, searchText, searchPlaceholder } = params;
|
|
2393
|
+
if (action === 'search' && searchText !== undefined) {
|
|
2394
|
+
await frame.evaluate((text, ph) => {
|
|
2395
|
+
var inputs = document.querySelectorAll('.ant-drawer input[type=text]');
|
|
2396
|
+
for (var i = 0; i < inputs.length; i++) {
|
|
2397
|
+
if (!ph || (inputs[i].placeholder && inputs[i].placeholder.includes(ph))) {
|
|
2398
|
+
inputs[i].focus(); inputs[i].value = text;
|
|
2399
|
+
inputs[i].dispatchEvent(new InputEvent('input', { bubbles: true }));
|
|
2400
|
+
inputs[i].dispatchEvent(new Event('change', { bubbles: true }));
|
|
2401
|
+
break;
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
}, searchText, searchPlaceholder);
|
|
2405
|
+
await frame.evaluate(function() {
|
|
2406
|
+
var btns = document.querySelectorAll('.ant-drawer button');
|
|
2407
|
+
for (var i = 0; i < btns.length; i++) { if (btns[i].textContent.trim() === '查 询') { btns[i].click(); return; } }
|
|
2408
|
+
});
|
|
2409
|
+
await new Promise(function(r) { setTimeout(r, 500); });
|
|
2410
|
+
}
|
|
2411
|
+
if (action === 'select' && radioIndex !== undefined) {
|
|
2412
|
+
await frame.evaluate(function(idx) {
|
|
2413
|
+
var radios = document.querySelectorAll('.ant-drawer .ant-radio-input');
|
|
2414
|
+
if (radios[idx]) radios[idx].click();
|
|
2415
|
+
}, radioIndex);
|
|
2416
|
+
}
|
|
2417
|
+
if (action === 'confirm') {
|
|
2418
|
+
await frame.evaluate(function() {
|
|
2419
|
+
var btns = document.querySelectorAll('.ant-drawer button');
|
|
2420
|
+
for (var i = 0; i < btns.length; i++) { if (btns[i].textContent.trim().replace(/\s+/g, '').indexOf('确认选择') >= 0) { btns[i].click(); return; } }
|
|
2421
|
+
});
|
|
2422
|
+
await new Promise(function(r) { setTimeout(r, 500); });
|
|
2423
|
+
}
|
|
2424
|
+
if (action === 'cancel') {
|
|
2425
|
+
await frame.evaluate(function() {
|
|
2426
|
+
var btns = document.querySelectorAll('.ant-drawer button');
|
|
2427
|
+
for (var i = 0; i < btns.length; i++) { if (btns[i].textContent.trim().replace(/\s+/g, '').indexOf('取消') >= 0) { btns[i].click(); return; } }
|
|
2428
|
+
});
|
|
2429
|
+
}
|
|
2430
|
+
return { acted: true, passed: true, action: action, status: 'PASS' };
|
|
2431
|
+
},
|
|
2432
|
+
'modal-action': async (page, frame, params) => {
|
|
2433
|
+
var action = params.action;
|
|
2434
|
+
var pattern = params.buttonText || (action === 'confirm' ? '确定|OK|确认' : '取消|Cancel');
|
|
2435
|
+
await frame.evaluate(function(p) {
|
|
2436
|
+
var btns = document.querySelectorAll('.ant-modal button, .ant-modal-confirm button, .ant-popconfirm button');
|
|
2437
|
+
var re = new RegExp(p);
|
|
2438
|
+
for (var i = 0; i < btns.length; i++) { if (re.test(btns[i].textContent.trim().replace(/\s+/g, ''))) { btns[i].click(); return; } }
|
|
2439
|
+
}, pattern);
|
|
2440
|
+
await new Promise(function(r) { setTimeout(r, 500); });
|
|
2441
|
+
return { acted: true, passed: true, action: action, status: 'PASS' };
|
|
2442
|
+
},
|
|
2443
|
+
'switch-tab': async (page, frame, params, browser) => {
|
|
2444
|
+
var urlContains = params.urlContains, titleIncludes = params.titleIncludes, pageIndex = params.pageIndex;
|
|
2445
|
+
var pages = await browser.pages();
|
|
2446
|
+
var target = null;
|
|
2447
|
+
if (urlContains) target = pages.find(function(p) { return p.url().indexOf(urlContains) >= 0; });
|
|
2448
|
+
else if (titleIncludes) { for (var p = 0; p < pages.length; p++) { if ((await pages[p].title().catch(function() { return ''; })).indexOf(titleIncludes) >= 0) { target = pages[p]; break; } } }
|
|
2449
|
+
else if (pageIndex !== undefined) target = pages[pageIndex];
|
|
2450
|
+
if (!target) return { acted: false, passed: false, reason: 'tab not found: ' + (urlContains || titleIncludes || pageIndex), status: 'FAIL' };
|
|
2451
|
+
await target.bringToFront();
|
|
2452
|
+
return { acted: true, passed: true, url: target.url(), status: 'PASS' };
|
|
2453
|
+
},
|
|
2454
|
+
'wait-api': async (page, frame, params) => {
|
|
2455
|
+
var urlPattern = params.urlPattern, timeout = params.timeout || 10000, method = params.method;
|
|
2456
|
+
return new Promise(function(resolve) {
|
|
2457
|
+
var timer = setTimeout(function() { resolve({ acted: false, passed: false, reason: 'timeout: ' + urlPattern, status: 'FAIL' }); }, timeout);
|
|
2458
|
+
page.on('response', function(response) {
|
|
2459
|
+
if (response.url().indexOf(urlPattern) >= 0 && (!method || response.request().method() === method.toUpperCase())) {
|
|
2460
|
+
clearTimeout(timer);
|
|
2461
|
+
resolve({ acted: true, passed: response.status() < 400, status: response.status(), url: response.url().slice(0, 100) });
|
|
2462
|
+
}
|
|
2463
|
+
});
|
|
2464
|
+
});
|
|
2465
|
+
},
|
|
2466
|
+
'get-table-data': async (page, frame, params) => {
|
|
2467
|
+
var selector = params.selector || '.ant-table', maxRows = params.maxRows || 50;
|
|
2468
|
+
var data = await frame.evaluate(function(sel, max) {
|
|
2469
|
+
var table = document.querySelector(sel);
|
|
2470
|
+
if (!table) return { found: false, headers: [], rows: [] };
|
|
2471
|
+
var headers = []; table.querySelectorAll('.ant-table-thead th').forEach(function(th) { headers.push(th.textContent.trim()); });
|
|
2472
|
+
var rows = []; var trs = table.querySelectorAll('.ant-table-tbody tr');
|
|
2473
|
+
for (var i = 0; i < Math.min(trs.length, max); i++) {
|
|
2474
|
+
var cells = trs[i].querySelectorAll('td');
|
|
2475
|
+
rows.push(Array.from(cells).map(function(c) { return { text: c.textContent.trim().slice(0, 80), actions: Array.from(c.querySelectorAll('a,button')).map(function(l) { return l.textContent.trim(); }).filter(Boolean) }; }));
|
|
2476
|
+
}
|
|
2477
|
+
return { found: true, headers: headers, rows: rows, rowCount: trs.length };
|
|
2478
|
+
}, selector, maxRows);
|
|
2479
|
+
return { acted: true, passed: data.found, data: data, status: data.found ? 'PASS' : 'FAIL' };
|
|
2480
|
+
},
|
|
2481
|
+
'login': async (page, frame, params, browser, engine) => {
|
|
2482
|
+
var username = params.username, password = params.password;
|
|
2483
|
+
if (!username) return { acted: false, passed: false, reason: 'login requires username', status: 'FAIL' };
|
|
2484
|
+
// 如果没有提供密码,从缓存凭据中查找
|
|
2485
|
+
if (!password && engine && engine._credentials) {
|
|
2486
|
+
var cred = engine._credentials.find(function(c) { return c.username === username; });
|
|
2487
|
+
if (cred) password = cred.password;
|
|
2488
|
+
}
|
|
2489
|
+
if (!password) return { acted: false, passed: false, reason: 'password not found for: ' + username, status: 'FAIL' };
|
|
2490
|
+
var captcha = params.captcha || '6';
|
|
2491
|
+
var maxRetries = params.maxRetries || 3;
|
|
2492
|
+
|
|
2493
|
+
// 0. 构建带参登录URL
|
|
2494
|
+
var appId = params.appId, viewId = params.viewId;
|
|
2495
|
+
if (!appId) {
|
|
2496
|
+
try {
|
|
2497
|
+
for (var k = 0; k < page.frames().length; k++) {
|
|
2498
|
+
try {
|
|
2499
|
+
var fu = page.frames()[k].url();
|
|
2500
|
+
var am = fu.match(/appId=(\\d+)/);
|
|
2501
|
+
var vm = fu.match(/viewId=(\\d+)/);
|
|
2502
|
+
if (am) { appId = am[1]; if (vm) viewId = vm[1]; break; }
|
|
2503
|
+
} catch (e) { /* skip */ }
|
|
2504
|
+
}
|
|
2505
|
+
} catch (e) { /* ignore */ }
|
|
2506
|
+
}
|
|
2507
|
+
var loginUrl = params.loginUrl || 'http://platform.aicityos.com:32222/ucenter/login/';
|
|
2508
|
+
if (appId && viewId) loginUrl += '#/user/login?appId=' + appId + '&viewId=' + viewId;
|
|
2509
|
+
|
|
2510
|
+
// 尝试登录,验证码过期自动重试
|
|
2511
|
+
for (var attempt = 0; attempt < maxRetries; attempt++) {
|
|
2512
|
+
// 导航到登录页(重试时刷新获取新验证码)
|
|
2513
|
+
await page.goto(loginUrl, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(function(){});
|
|
2514
|
+
await new Promise(function(r) { setTimeout(r, attempt === 0 ? 2000 : 1000); });
|
|
2515
|
+
|
|
2516
|
+
// 1. 使用 antInput 统一填表
|
|
2517
|
+
var steps = [
|
|
2518
|
+
{ placeholder: '请输入账户', value: username },
|
|
2519
|
+
{ placeholder: '请输入密码', value: password },
|
|
2520
|
+
{ placeholder: '请输入图形验证码', value: captcha },
|
|
2521
|
+
];
|
|
2522
|
+
for (var i = 0; i < steps.length; i++) {
|
|
2523
|
+
var s = steps[i];
|
|
2524
|
+
var adapter = new (await import('./ant-adapter.js')).AntAdapter();
|
|
2525
|
+
await adapter.input(frame, { placeholder: s.placeholder }, s.value, { clear: true, stepDelay: 200 });
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
// 2. 点击登录
|
|
2529
|
+
await frame.evaluate(function() {
|
|
2530
|
+
var btns = document.querySelectorAll('button');
|
|
2531
|
+
for (var i = 0; i < btns.length; i++) {
|
|
2532
|
+
var t = btns[i].textContent || '';
|
|
2533
|
+
if (t.indexOf('登') >= 0 && t.indexOf('录') >= 0) { btns[i].click(); return 'clicked'; }
|
|
2534
|
+
}
|
|
2535
|
+
return 'not found';
|
|
2536
|
+
});
|
|
2537
|
+
await new Promise(function(r) { setTimeout(r, 4000); });
|
|
2538
|
+
|
|
2539
|
+
// 3. 检查是否登录成功
|
|
2540
|
+
var loggedIn = page.url().indexOf('/login') < 0;
|
|
2541
|
+
if (loggedIn) {
|
|
2542
|
+
return { acted: true, passed: true, username: username, loggedIn: true, appId: appId, viewId: viewId, url: page.url(), attempts: attempt + 1, status: 'PASS' };
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
// 失败时可能是验证码过期,检查是否有错误提示
|
|
2546
|
+
var hasError = await frame.evaluate(function() {
|
|
2547
|
+
var errs = document.querySelectorAll('.ant-message-notice,.ant-form-item-explain-error');
|
|
2548
|
+
for (var e = 0; e < errs.length; e++) {
|
|
2549
|
+
var t = errs[e].textContent.trim();
|
|
2550
|
+
if (t && (t.indexOf('验证码') >= 0 || t.indexOf('错误') >= 0)) return true;
|
|
2551
|
+
}
|
|
2552
|
+
return false;
|
|
2553
|
+
}).catch(function() { return false; });
|
|
2554
|
+
|
|
2555
|
+
if (attempt < maxRetries - 1) {
|
|
2556
|
+
process.stderr.write('[cdpScript:login] 验证码可能过期,刷新重试 (' + (attempt + 2) + '/' + maxRetries + ')...\n');
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
return { acted: true, passed: false, username: username, loggedIn: false, appId: appId, viewId: viewId, url: page.url(), attempts: maxRetries, status: 'FAIL', reason: 'login failed after ' + maxRetries + ' attempts' };
|
|
2561
|
+
},
|
|
2562
|
+
'logout': async (page, frame, params, browser) => {
|
|
2563
|
+
var appUrl = params.appUrl || '/szc-ai-data/';
|
|
2564
|
+
var frames = page.frames();
|
|
2565
|
+
var childFrame = frames.find(function(f) { return f.url().indexOf(appUrl) >= 0; });
|
|
2566
|
+
if (!childFrame && frames.length > 1) childFrame = frames[1];
|
|
2567
|
+
|
|
2568
|
+
// 1. 从主 frame 获取 appId/viewId(wujie 父应用注入)
|
|
2569
|
+
var appId = null, viewId = null;
|
|
2570
|
+
var mainFrame = page.mainFrame();
|
|
2571
|
+
try {
|
|
2572
|
+
var wujieProps = await mainFrame.evaluate(function() {
|
|
2573
|
+
if (window.$wujie && window.$wujie.props) {
|
|
2574
|
+
return { appId: window.$wujie.props.appId, viewId: window.$wujie.props.viewId };
|
|
2575
|
+
}
|
|
2576
|
+
// 退而求其次:从 URL 解析
|
|
2577
|
+
var hash = location.hash || '';
|
|
2578
|
+
var appIdMatch = hash.match(/appId=(\d+)/);
|
|
2579
|
+
var viewIdMatch = hash.match(/viewId=(\d+)/);
|
|
2580
|
+
if (appIdMatch) return { appId: appIdMatch[1], viewId: viewIdMatch ? viewIdMatch[1] : null };
|
|
2581
|
+
return null;
|
|
2582
|
+
});
|
|
2583
|
+
if (wujieProps) { appId = wujieProps.appId; viewId = wujieProps.viewId; }
|
|
2584
|
+
} catch (e) { /* ignore */ }
|
|
2585
|
+
if (!appId && childFrame) {
|
|
2586
|
+
try {
|
|
2587
|
+
// 从子 frame URL 解析
|
|
2588
|
+
var cfUrl = childFrame.url();
|
|
2589
|
+
var appMatch = cfUrl.match(/appId=(\d+)/);
|
|
2590
|
+
var viewMatch = cfUrl.match(/viewId=(\d+)/);
|
|
2591
|
+
if (appMatch) { appId = appMatch[1]; viewId = viewMatch ? viewMatch[1] : null; }
|
|
2592
|
+
} catch (e) { /* ignore */ }
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
// 2. 尝试调用 loginOut
|
|
2596
|
+
var loginOutResult = null;
|
|
2597
|
+
if (childFrame) {
|
|
2598
|
+
loginOutResult = await childFrame.evaluate(function() {
|
|
2599
|
+
try {
|
|
2600
|
+
if (window.$wujie && window.$wujie.props && typeof window.$wujie.props.loginOut === 'function') {
|
|
2601
|
+
window.$wujie.props.loginOut();
|
|
2602
|
+
return 'loginOut called';
|
|
2603
|
+
}
|
|
2604
|
+
} catch (e) { return 'error: ' + e.message; }
|
|
2605
|
+
return 'no loginOut prop';
|
|
2606
|
+
}).catch(function() { return 'evaluate failed'; });
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
// 3. 兜底:清除所有 cookie
|
|
2610
|
+
await page.evaluate(function() {
|
|
2611
|
+
document.cookie.split(';').forEach(function(c) {
|
|
2612
|
+
var name = c.replace(/^ +/, '').split('=')[0];
|
|
2613
|
+
document.cookie = name + '=;expires=' + new Date(0).toUTCString() + ';path=/';
|
|
2614
|
+
});
|
|
2615
|
+
});
|
|
2616
|
+
|
|
2617
|
+
// 4. 构建带参数的登录 URL(保留 appId/viewId)
|
|
2618
|
+
var loginUrl = 'http://platform.aicityos.com:32222/ucenter/login/';
|
|
2619
|
+
if (appId && viewId) {
|
|
2620
|
+
loginUrl += '#/user/login?appId=' + appId + '&viewId=' + viewId;
|
|
2621
|
+
}
|
|
2622
|
+
await page.goto(loginUrl, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(function(){});
|
|
2623
|
+
await new Promise(function(r) { setTimeout(r, 2000); });
|
|
2624
|
+
|
|
2625
|
+
var loggedOut = page.url().indexOf('/login') >= 0;
|
|
2626
|
+
return { acted: true, passed: loggedOut, loginOutResult: loginOutResult, appId: appId, viewId: viewId, loggedOut: loggedOut, url: page.url(), status: loggedOut ? 'PASS' : 'FAIL' };
|
|
2627
|
+
},
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
_registerCdpScript(step) {
|
|
2632
|
+
if (!this._cdpScripts) this._cdpScripts = {};
|
|
2633
|
+
try {
|
|
2634
|
+
this._cdpScripts[step.name] = new Function('page', 'frame', 'params', 'browser', 'return (' + step.code + ')(page, frame, params, browser)');
|
|
2635
|
+
return { type: 'registerCdpScript', name: step.name, registered: true, status: 'PASS', passed: true };
|
|
2636
|
+
} catch (e) {
|
|
2637
|
+
return { type: 'registerCdpScript', name: step.name, registered: false, reason: 'compile error: ' + e.message, status: 'FAIL', passed: false };
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
async _cdpScript(step) {
|
|
2642
|
+
var scripts = this._cdpScripts || {};
|
|
2643
|
+
var fn = scripts[step.script];
|
|
2644
|
+
if (!fn) return { type: 'cdpScript', passed: false, reason: 'cdpScript "' + step.script + '" not registered', status: 'FAIL' };
|
|
2645
|
+
var target = await this._getTargetFrame(step);
|
|
2646
|
+
var result = await fn(this.page, target.frame, step.params || {}, this.browser, this);
|
|
2647
|
+
return Object.assign({ type: 'cdpScript', script: step.script }, result);
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
async _rawScript(step) {
|
|
2651
|
+
var target = await this._getTargetFrame(step);
|
|
2652
|
+
var fn;
|
|
2653
|
+
try { fn = new Function('page', 'frame', 'params', 'browser', 'return (' + step.code + ')(page, frame, params, browser)'); }
|
|
2654
|
+
catch (e) { return { type: 'rawScript', passed: false, reason: 'script compile error: ' + e.message, status: 'FAIL' }; }
|
|
2655
|
+
try {
|
|
2656
|
+
var result = await fn(this.page, target.frame, step.params || {}, this.browser);
|
|
2657
|
+
return Object.assign({ type: 'rawScript' }, result);
|
|
2658
|
+
} catch (e) { return { type: 'rawScript', passed: false, reason: 'script execution error: ' + e.message, status: 'FAIL' }; }
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
async _apiCall(step) {
|
|
2662
|
+
var method = (step.method || 'POST').toUpperCase();
|
|
2663
|
+
var response = await this.page.evaluate(async function(opts) {
|
|
2664
|
+
var res = await fetch(opts.url, { method: opts.method, headers: Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {}), body: opts.body ? JSON.stringify(opts.body) : undefined });
|
|
2665
|
+
var data = await res.text();
|
|
2666
|
+
try { return { status: res.status, data: JSON.parse(data) }; } catch(e) { return { status: res.status, data: data }; }
|
|
2667
|
+
}, { method: method, url: step.url, body: step.body, headers: step.headers });
|
|
2668
|
+
var passed = response.status >= 200 && response.status < 300;
|
|
2669
|
+
return { type: 'apiCall', status: response.status, data: typeof response.data === 'string' ? response.data.slice(0, 500) : response.data, passed: passed, reason: passed ? null : 'API returned ' + response.status };
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
async _switchPage(step) {
|
|
2673
|
+
var pages = await this.browser.pages();
|
|
2674
|
+
var target = null;
|
|
2675
|
+
if (step.urlContains) target = pages.find(function(p) { return p.url().indexOf(step.urlContains) >= 0; });
|
|
2676
|
+
else if (step.titleIncludes) { for (var p = 0; p < pages.length; p++) { if ((await pages[p].title().catch(function() { return ''; })).indexOf(step.titleIncludes) >= 0) { target = pages[p]; break; } } }
|
|
2677
|
+
else if (step.pageIndex !== undefined) target = pages[step.pageIndex];
|
|
2678
|
+
if (!target) return { type: 'switchPage', passed: false, reason: 'page not found: ' + (step.urlContains || step.titleIncludes || step.pageIndex), totalPages: pages.length, status: 'FAIL' };
|
|
2679
|
+
await target.bringToFront();
|
|
2680
|
+
this.page = target;
|
|
2681
|
+
this._activeFrame = null;
|
|
2682
|
+
var url = target.url();
|
|
2683
|
+
return { type: 'switchPage', passed: true, url: url, title: await target.title().catch(function() { return ''; }), status: 'PASS' };
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2258
2686
|
/**
|
|
2259
2687
|
* 执行测试用例:支持 dataset 参数化、变量替换
|
|
2260
2688
|
* @param {object} testCase - 用例配置
|
|
@@ -2266,7 +2694,18 @@ export class BrowserEngine {
|
|
|
2266
2694
|
const results = [];
|
|
2267
2695
|
const dataset = testCase.dataset || [{ __row: 0 }];
|
|
2268
2696
|
|
|
2269
|
-
//
|
|
2697
|
+
// 自动开启浏览器错误和网络监控
|
|
2698
|
+
const monitor = await this._startAutoCapture();
|
|
2699
|
+
|
|
2700
|
+
// setup 阶段:page 切换 + frame 预定位
|
|
2701
|
+
if (testCase.setup?.findPage) {
|
|
2702
|
+
const pageResult = await this._findPage(testCase.setup.findPage);
|
|
2703
|
+
if (!pageResult.found) {
|
|
2704
|
+
throw new Error(`findPage failed: ${pageResult.error} - available pages: ${JSON.stringify(pageResult.pages?.map(p => p.url))}`);
|
|
2705
|
+
}
|
|
2706
|
+
// page 切换后需要重新检测 wujie 模式
|
|
2707
|
+
this._wujieContext = await this._detectWujieMode();
|
|
2708
|
+
}
|
|
2270
2709
|
if (testCase.setup?.findFrame && !this._activeFrame) {
|
|
2271
2710
|
const resolved = await this._resolveObserveFrame(testCase.setup.findFrame);
|
|
2272
2711
|
this._activeFrame = resolved.frame;
|
|
@@ -2354,15 +2793,41 @@ export class BrowserEngine {
|
|
|
2354
2793
|
const passed = results.filter((r) => r.status === "PASS").length;
|
|
2355
2794
|
const failed = results.filter((r) => r.status === "FAIL").length;
|
|
2356
2795
|
|
|
2796
|
+
// 停止监控并收集数据
|
|
2797
|
+
const capturedErrors = await this._stopAutoCapture(monitor);
|
|
2798
|
+
|
|
2799
|
+
// 生成诊断提示(帮助 LLM 推断失败原因)
|
|
2800
|
+
const diagnosticHint = failed > 0 ? this._generateDiagnosticHint(results, capturedErrors) : null;
|
|
2801
|
+
|
|
2802
|
+
// L3 自动修复:根据诊断结果尝试恢复并重试
|
|
2803
|
+
var autoRecovery = null;
|
|
2804
|
+
var shouldAutoRecover = testCase.options?.autoRecover !== false && failed > 0;
|
|
2805
|
+
if (shouldAutoRecover && diagnosticHint) {
|
|
2806
|
+
autoRecovery = await this._attemptAutoRecovery(testCase, context, diagnosticHint, capturedErrors);
|
|
2807
|
+
if (autoRecovery?.retryResults) {
|
|
2808
|
+
for (var ri = 0; ri < autoRecovery.retryResults.length; ri++) {
|
|
2809
|
+
results.push(autoRecovery.retryResults[ri]);
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
var finalPassed = results.filter(function(r) { return r.status === 'PASS'; }).length;
|
|
2815
|
+
var finalFailed = results.filter(function(r) { return r.status === 'FAIL'; }).length;
|
|
2816
|
+
|
|
2357
2817
|
return {
|
|
2358
2818
|
title: testCase.title,
|
|
2359
2819
|
datasetSize: dataset.length,
|
|
2360
2820
|
totalSteps: results.length,
|
|
2361
|
-
passedSteps:
|
|
2362
|
-
failedSteps:
|
|
2363
|
-
passed:
|
|
2821
|
+
passedSteps: finalPassed,
|
|
2822
|
+
failedSteps: finalFailed,
|
|
2823
|
+
passed: finalFailed === 0,
|
|
2364
2824
|
duration,
|
|
2365
2825
|
results,
|
|
2826
|
+
browserErrors: capturedErrors.errors,
|
|
2827
|
+
failedRequests: capturedErrors.failedRequests,
|
|
2828
|
+
consoleErrors: capturedErrors.consoleErrors,
|
|
2829
|
+
diagnosticHint,
|
|
2830
|
+
autoRecovery: autoRecovery ? { strategies: autoRecovery.strategies, recovered: autoRecovery.recovered, retries: autoRecovery.retryCount } : null,
|
|
2366
2831
|
summary: {
|
|
2367
2832
|
perDataset: results.reduce((acc, r) => {
|
|
2368
2833
|
const key = r.dataRowIndex;
|
|
@@ -2396,10 +2861,229 @@ export class BrowserEngine {
|
|
|
2396
2861
|
}
|
|
2397
2862
|
return obj;
|
|
2398
2863
|
}
|
|
2864
|
+
|
|
2865
|
+
async _startAutoCapture() {
|
|
2866
|
+
var self = this;
|
|
2867
|
+
var monitor = { jsErrors: [], consoleErrors: [], failedRequests: [], cdpSessions: [] };
|
|
2868
|
+
try {
|
|
2869
|
+
if (!self.page || !self.page.target) { monitor = { jsErrors: [], consoleErrors: [], failedRequests: [], cdpSessions: [] }; return monitor; }
|
|
2870
|
+
var cdp = await self.page.target().createCDPSession();
|
|
2871
|
+
monitor.cdpSessions.push(cdp);
|
|
2872
|
+
await cdp.send('Runtime.enable');
|
|
2873
|
+
cdp.on('Runtime.exceptionThrown', function(event) {
|
|
2874
|
+
var d = event.exceptionDetails || {};
|
|
2875
|
+
monitor.jsErrors.push({ text: d.text || '', url: d.url || '', line: d.lineNumber || 0, time: Date.now() });
|
|
2876
|
+
});
|
|
2877
|
+
self.page.on('console', function(msg) {
|
|
2878
|
+
if (msg.type() === 'error') monitor.consoleErrors.push({ text: msg.text(), time: Date.now() });
|
|
2879
|
+
});
|
|
2880
|
+
await cdp.send('Network.enable');
|
|
2881
|
+
cdp.on('Network.responseReceived', function(event) {
|
|
2882
|
+
var r = event.response || {};
|
|
2883
|
+
if (r.status >= 400) monitor.failedRequests.push({ url: r.url, status: r.status, time: Date.now() });
|
|
2884
|
+
});
|
|
2885
|
+
} catch(e) { process.stderr.write('[auto-capture] setup: '+e.message+'\n'); }
|
|
2886
|
+
return monitor;
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
async _stopAutoCapture(monitor) {
|
|
2890
|
+
if (!monitor) return { errors: [], failedRequests: [], consoleErrors: [] };
|
|
2891
|
+
try {
|
|
2892
|
+
for (var i = 0; i < monitor.cdpSessions.length; i++) {
|
|
2893
|
+
try { monitor.cdpSessions[i].send('Runtime.disable').catch(function(){}); } catch(e) {}
|
|
2894
|
+
try { monitor.cdpSessions[i].send('Network.disable').catch(function(){}); } catch(e) {}
|
|
2895
|
+
}
|
|
2896
|
+
} catch(e) {}
|
|
2897
|
+
return { errors: monitor.jsErrors || [], failedRequests: monitor.failedRequests || [], consoleErrors: monitor.consoleErrors || [] };
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
/**
|
|
2901
|
+
* L2 智能诊断:分析失败步骤和捕获的错误,生成结构化诊断提示
|
|
2902
|
+
* 帮助 LLM 结合 SKILL.md 上下文推断失败原因并决定恢复策略
|
|
2903
|
+
*/
|
|
2904
|
+
_generateDiagnosticHint(results, capturedErrors) {
|
|
2905
|
+
var failedSteps = results.filter(function(r) { return r.status === 'FAIL'; });
|
|
2906
|
+
var hints = [];
|
|
2907
|
+
var suggestions = [];
|
|
2908
|
+
|
|
2909
|
+
// 1. 分析失败步骤
|
|
2910
|
+
failedSteps.forEach(function(step) {
|
|
2911
|
+
var info = { step: step.step, stepIndex: step.stepIndex, dataRowIndex: step.dataRowIndex };
|
|
2912
|
+
if (step.error) info.error = step.error;
|
|
2913
|
+
if (step.reason) info.reason = step.reason;
|
|
2914
|
+
hints.push(info);
|
|
2915
|
+
});
|
|
2916
|
+
|
|
2917
|
+
// 2. 分析网络错误
|
|
2918
|
+
var failedReqs = capturedErrors.failedRequests || [];
|
|
2919
|
+
if (failedReqs.length > 0) {
|
|
2920
|
+
var statusCodes = {};
|
|
2921
|
+
failedReqs.forEach(function(r) {
|
|
2922
|
+
statusCodes[r.status] = (statusCodes[r.status] || 0) + 1;
|
|
2923
|
+
});
|
|
2924
|
+
hints.push({ type: 'network', failedCount: failedReqs.length, statusCodes: statusCodes, samples: failedReqs.slice(0, 3) });
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
// 3. 分析 JS 错误
|
|
2928
|
+
if (capturedErrors.errors && capturedErrors.errors.length > 0) {
|
|
2929
|
+
hints.push({ type: 'browserErrors', count: capturedErrors.errors.length, samples: capturedErrors.errors.slice(0, 5) });
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
// 4. 推断建议
|
|
2933
|
+
if (failedReqs.some(function(r) { return r.status === 401 || r.status === 403; })) {
|
|
2934
|
+
suggestions.push('检测到 401/403 响应,session 可能已过期,建议重新登录');
|
|
2935
|
+
}
|
|
2936
|
+
if (failedReqs.some(function(r) { return r.status >= 500; })) {
|
|
2937
|
+
suggestions.push('服务器 5xx 错误,可能是后端服务异常,建议稍后重试或检查服务状态');
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
var loginFailed = failedSteps.some(function(s) {
|
|
2941
|
+
return s.step === 'cdpScript' && (s.script === 'login') || s.step === 'antInput';
|
|
2942
|
+
});
|
|
2943
|
+
var loggedIn = results.some(function(s) { return s.status === 'PASS' && (s.loggedIn === true); });
|
|
2944
|
+
if (loginFailed && !loggedIn && failedReqs.length === 0) {
|
|
2945
|
+
suggestions.push('登录步骤失败但无网络错误,可能是验证码过期,建议刷新登录页重试(验证码默认值 6)');
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
if (capturedErrors.errors && capturedErrors.errors.length > 0) {
|
|
2949
|
+
var jsPatterns = capturedErrors.errors.map(function(e) { return e.text; }).filter(Boolean);
|
|
2950
|
+
if (jsPatterns.some(function(t) { return t.indexOf('Cannot read') >= 0 || t.indexOf('undefined') >= 0; })) {
|
|
2951
|
+
suggestions.push('存在 JS 空指针异常,可能是页面元素未渲染完成即执行操作,建议增加 wait 步骤');
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
return {
|
|
2956
|
+
failedAt: hints,
|
|
2957
|
+
suggestions: suggestions,
|
|
2958
|
+
summary: failedSteps.length + ' 个步骤失败, ' + failedReqs.length + ' 个请求异常, ' + (capturedErrors.errors ? capturedErrors.errors.length : 0) + ' 个 JS 错误',
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
/**
|
|
2963
|
+
* L3 自动修复:根据诊断结果执行恢复策略并重试失败步骤
|
|
2964
|
+
*/
|
|
2965
|
+
async _attemptAutoRecovery(testCase, context, diagnosticHint, capturedErrors) {
|
|
2966
|
+
var maxRetries = (testCase.options && testCase.options.autoRecoverRetries) || 2;
|
|
2967
|
+
var strategies = [];
|
|
2968
|
+
var retryResults = [];
|
|
2969
|
+
var recovered = false;
|
|
2970
|
+
var retryCount = 0;
|
|
2971
|
+
var dataRow = (testCase.dataset && testCase.dataset[0]) || {};
|
|
2972
|
+
|
|
2973
|
+
// 匹配恢复策略
|
|
2974
|
+
var hasLoginFailure = diagnosticHint.failedAt.some(function(f) {
|
|
2975
|
+
return f.step === 'cdpScript' || f.step === 'antInput';
|
|
2976
|
+
});
|
|
2977
|
+
var hasNetworkError = (capturedErrors.failedRequests || []).length > 0;
|
|
2978
|
+
var hasJsError = (capturedErrors.errors || []).length > 0;
|
|
2979
|
+
var hasElementNotFound = diagnosticHint.failedAt.some(function(f) {
|
|
2980
|
+
return (f.error || '').indexOf('not found') >= 0;
|
|
2981
|
+
});
|
|
2982
|
+
|
|
2983
|
+
// 策略 A:登录失败 → 获取新验证码 → 重新登录
|
|
2984
|
+
if (hasLoginFailure && !hasNetworkError) {
|
|
2985
|
+
strategies.push({ type: 'login', action: '刷新登录页获取新验证码后重新登录' });
|
|
2986
|
+
for (var attempt = 0; attempt < maxRetries && !recovered; attempt++) {
|
|
2987
|
+
retryCount++;
|
|
2988
|
+
process.stderr.write('[auto-recovery] 尝试恢复登录 (' + (attempt + 1) + '/' + maxRetries + ')...\n');
|
|
2989
|
+
|
|
2990
|
+
// 重新执行所有包含登录的步骤
|
|
2991
|
+
var allPassed = true;
|
|
2992
|
+
for (var si = 0; si < testCase.steps.length; si++) {
|
|
2993
|
+
var rawStep = testCase.steps[si];
|
|
2994
|
+
var step = this._substituteVariables(rawStep, testCase.dataset ? testCase.dataset[0] : {});
|
|
2995
|
+
|
|
2996
|
+
// 只重试导航和登录相关步骤
|
|
2997
|
+
if (step.type === 'navigate' || step.type === 'antInput' || step.type === 'act' ||
|
|
2998
|
+
(step.type === 'cdpScript' && (step.script === 'login'))) {
|
|
2999
|
+
try {
|
|
3000
|
+
var result = await this.executeStep(step, context);
|
|
3001
|
+
retryResults.push(Object.assign({}, result, { stepIndex: si, dataRowIndex: 0, recovery: true, recoveryAttempt: attempt + 1 }));
|
|
3002
|
+
if (result.status === 'FAIL') allPassed = false;
|
|
3003
|
+
} catch(e) {
|
|
3004
|
+
retryResults.push({ step: step.type, status: 'FAIL', error: e.message, stepIndex: si, dataRowIndex: 0, recovery: true, recoveryAttempt: attempt + 1 });
|
|
3005
|
+
allPassed = false;
|
|
3006
|
+
}
|
|
3007
|
+
} else if (allPassed) {
|
|
3008
|
+
// 登录成功后才执行后续步骤
|
|
3009
|
+
try {
|
|
3010
|
+
var r2 = await this.executeStep(step, context);
|
|
3011
|
+
retryResults.push(Object.assign({}, r2, { stepIndex: si, dataRowIndex: 0, recovery: true }));
|
|
3012
|
+
} catch(e) {
|
|
3013
|
+
retryResults.push({ step: step.type, status: 'FAIL', error: e.message, stepIndex: si, dataRowIndex: 0, recovery: true });
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
if (allPassed) {
|
|
3019
|
+
recovered = true;
|
|
3020
|
+
strategies[strategies.length - 1].result = 'success';
|
|
3021
|
+
} else if (attempt < maxRetries - 1) {
|
|
3022
|
+
strategies[strategies.length - 1].result = 'retrying';
|
|
3023
|
+
} else {
|
|
3024
|
+
strategies[strategies.length - 1].result = 'exhausted';
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
// 策略 B:元素未找到 → 等待并重试
|
|
3030
|
+
if (hasElementNotFound && !recovered) {
|
|
3031
|
+
strategies.push({ type: 'elementNotFound', action: '等待页面渲染后重试' });
|
|
3032
|
+
for (var ea = 0; ea < maxRetries && !recovered; ea++) {
|
|
3033
|
+
retryCount++;
|
|
3034
|
+
process.stderr.write('[auto-recovery] 等待页面渲染后重试 (' + (ea + 1) + '/' + maxRetries + ')...\n');
|
|
3035
|
+
await new Promise(function(r) { setTimeout(r, 3000); });
|
|
3036
|
+
var ep = true;
|
|
3037
|
+
for (var si2 = 0; si2 < testCase.steps.length; si2++) {
|
|
3038
|
+
var s2 = this._substituteVariables(testCase.steps[si2], dataRow);
|
|
3039
|
+
try {
|
|
3040
|
+
var r3 = await this.executeStep(s2, context);
|
|
3041
|
+
retryResults.push(Object.assign({}, r3, { stepIndex: si2, dataRowIndex: 0, recovery: true, recoveryAttempt: ea + 1 }));
|
|
3042
|
+
if (r3.status === 'FAIL') ep = false;
|
|
3043
|
+
} catch(e) {
|
|
3044
|
+
retryResults.push({ step: s2.type, status: 'FAIL', error: e.message, stepIndex: si2, dataRowIndex: 0, recovery: true });
|
|
3045
|
+
ep = false;
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
if (ep) { recovered = true; strategies[strategies.length - 1].result = 'success'; }
|
|
3049
|
+
else if (ea < maxRetries - 1) strategies[strategies.length - 1].result = 'retrying';
|
|
3050
|
+
else strategies[strategies.length - 1].result = 'exhausted';
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
// 策略 C:网络错误 → 页面重载 + 重试
|
|
3055
|
+
if (hasNetworkError && !recovered) {
|
|
3056
|
+
strategies.push({ type: 'networkError', action: '页面重载后重试' });
|
|
3057
|
+
for (var na = 0; na < maxRetries && !recovered; na++) {
|
|
3058
|
+
retryCount++;
|
|
3059
|
+
process.stderr.write('[auto-recovery] 网络错误,重载页面 (' + (na + 1) + '/' + maxRetries + ')...\n');
|
|
3060
|
+
try { await this.page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }); } catch(e) {}
|
|
3061
|
+
await new Promise(function(r) { setTimeout(r, 2000); });
|
|
3062
|
+
var np = true;
|
|
3063
|
+
for (var si3 = 0; si3 < testCase.steps.length; si3++) {
|
|
3064
|
+
var s3 = this._substituteVariables(testCase.steps[si3], dataRow);
|
|
3065
|
+
try {
|
|
3066
|
+
var r4 = await this.executeStep(s3, context);
|
|
3067
|
+
retryResults.push(Object.assign({}, r4, { stepIndex: si3, dataRowIndex: 0, recovery: true, recoveryAttempt: na + 1 }));
|
|
3068
|
+
if (r4.status === 'FAIL') np = false;
|
|
3069
|
+
} catch(e) {
|
|
3070
|
+
retryResults.push({ step: s3.type, status: 'FAIL', error: e.message, stepIndex: si3, dataRowIndex: 0, recovery: true });
|
|
3071
|
+
np = false;
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
if (np) { recovered = true; strategies[strategies.length - 1].result = 'success'; }
|
|
3075
|
+
else if (na < maxRetries - 1) strategies[strategies.length - 1].result = 'retrying';
|
|
3076
|
+
else strategies[strategies.length - 1].result = 'exhausted';
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
return { strategies: strategies, recovered: recovered, retryCount: retryCount, retryResults: retryResults };
|
|
3081
|
+
}
|
|
2399
3082
|
}
|
|
2400
3083
|
|
|
2401
3084
|
/**
|
|
2402
|
-
* 动态加载 puppeteer-core,从共享缓存目录加载,缺失时自动安装
|
|
3085
|
+
* 动态加载 puppeteer-core 和 sql.js,从共享缓存目录加载,缺失时自动安装
|
|
3086
|
+
* 主链路(observe/act/assert + 凭据解密)需要这两项依赖
|
|
2403
3087
|
*/
|
|
2404
3088
|
async function loadPuppeteer() {
|
|
2405
3089
|
const { ensureCoreDeps, loadFromShared } = await import("./shared-deps.js");
|
|
@@ -2412,7 +3096,7 @@ async function loadPuppeteer() {
|
|
|
2412
3096
|
// 本地未安装
|
|
2413
3097
|
}
|
|
2414
3098
|
|
|
2415
|
-
// 2.
|
|
3099
|
+
// 2. 安装主链路依赖(puppeteer-core + sql.js)到共享缓存
|
|
2416
3100
|
await ensureCoreDeps();
|
|
2417
3101
|
|
|
2418
3102
|
try {
|