@szc-ft/mcp-szcd-client 0.30.0 → 0.32.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 +229 -9
- package/opencode-extension/skills/local-browser-test/decrypt-creds.mjs +210 -0
- package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +642 -5
- 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/package.json +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +229 -9
- package/qwen-extension/skills/local-browser-test/decrypt-creds.mjs +210 -0
- package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +642 -5
- 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/scripts/lib/dev-browser-shortcut.js +230 -0
- package/scripts/postinstall.js +5 -0
- package/standard-skill/local-browser-test/SKILL.md +229 -9
- package/standard-skill/local-browser-test/decrypt-creds.mjs +210 -0
- package/standard-skill/local-browser-test/lib/browser-engine.js +642 -5
- 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
|
@@ -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
|
/**
|
|
@@ -232,15 +240,89 @@ export class BrowserEngine {
|
|
|
232
240
|
// 微前端模式检测
|
|
233
241
|
this._wujieContext = await this._detectWujieMode();
|
|
234
242
|
|
|
243
|
+
// 自动解密凭据(从浏览器密码管理器)
|
|
244
|
+
this._credentials = await this._decryptCredentials();
|
|
245
|
+
|
|
235
246
|
return {
|
|
236
247
|
mode: "connect",
|
|
237
248
|
cdpUrl,
|
|
238
249
|
currentUrl: this.page?.url() || null,
|
|
239
250
|
totalPages: pages.length,
|
|
240
251
|
allPageUrls: pages.map((p) => p.url()),
|
|
252
|
+
credentials: this._credentials, // 解密后的凭据
|
|
241
253
|
};
|
|
242
254
|
}
|
|
243
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
|
+
|
|
244
326
|
/**
|
|
245
327
|
* 模式 B:启动新的 Chrome
|
|
246
328
|
*/
|
|
@@ -1360,6 +1442,12 @@ export class BrowserEngine {
|
|
|
1360
1442
|
case "antUpload": return this._antUpload(step);
|
|
1361
1443
|
case "formFill": return this._formFill(step);
|
|
1362
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);
|
|
1363
1451
|
// ===== 语义断言层 =====
|
|
1364
1452
|
case "expect": return this._expect(step);
|
|
1365
1453
|
default:
|
|
@@ -2294,6 +2382,307 @@ export class BrowserEngine {
|
|
|
2294
2382
|
this.page = null;
|
|
2295
2383
|
}
|
|
2296
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
|
+
|
|
2297
2686
|
/**
|
|
2298
2687
|
* 执行测试用例:支持 dataset 参数化、变量替换
|
|
2299
2688
|
* @param {object} testCase - 用例配置
|
|
@@ -2305,6 +2694,9 @@ export class BrowserEngine {
|
|
|
2305
2694
|
const results = [];
|
|
2306
2695
|
const dataset = testCase.dataset || [{ __row: 0 }];
|
|
2307
2696
|
|
|
2697
|
+
// 自动开启浏览器错误和网络监控
|
|
2698
|
+
const monitor = await this._startAutoCapture();
|
|
2699
|
+
|
|
2308
2700
|
// setup 阶段:page 切换 + frame 预定位
|
|
2309
2701
|
if (testCase.setup?.findPage) {
|
|
2310
2702
|
const pageResult = await this._findPage(testCase.setup.findPage);
|
|
@@ -2401,15 +2793,41 @@ export class BrowserEngine {
|
|
|
2401
2793
|
const passed = results.filter((r) => r.status === "PASS").length;
|
|
2402
2794
|
const failed = results.filter((r) => r.status === "FAIL").length;
|
|
2403
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
|
+
|
|
2404
2817
|
return {
|
|
2405
2818
|
title: testCase.title,
|
|
2406
2819
|
datasetSize: dataset.length,
|
|
2407
2820
|
totalSteps: results.length,
|
|
2408
|
-
passedSteps:
|
|
2409
|
-
failedSteps:
|
|
2410
|
-
passed:
|
|
2821
|
+
passedSteps: finalPassed,
|
|
2822
|
+
failedSteps: finalFailed,
|
|
2823
|
+
passed: finalFailed === 0,
|
|
2411
2824
|
duration,
|
|
2412
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,
|
|
2413
2831
|
summary: {
|
|
2414
2832
|
perDataset: results.reduce((acc, r) => {
|
|
2415
2833
|
const key = r.dataRowIndex;
|
|
@@ -2443,10 +2861,229 @@ export class BrowserEngine {
|
|
|
2443
2861
|
}
|
|
2444
2862
|
return obj;
|
|
2445
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
|
+
}
|
|
2446
3082
|
}
|
|
2447
3083
|
|
|
2448
3084
|
/**
|
|
2449
|
-
* 动态加载 puppeteer-core,从共享缓存目录加载,缺失时自动安装
|
|
3085
|
+
* 动态加载 puppeteer-core 和 sql.js,从共享缓存目录加载,缺失时自动安装
|
|
3086
|
+
* 主链路(observe/act/assert + 凭据解密)需要这两项依赖
|
|
2450
3087
|
*/
|
|
2451
3088
|
async function loadPuppeteer() {
|
|
2452
3089
|
const { ensureCoreDeps, loadFromShared } = await import("./shared-deps.js");
|
|
@@ -2459,7 +3096,7 @@ async function loadPuppeteer() {
|
|
|
2459
3096
|
// 本地未安装
|
|
2460
3097
|
}
|
|
2461
3098
|
|
|
2462
|
-
// 2.
|
|
3099
|
+
// 2. 安装主链路依赖(puppeteer-core + sql.js)到共享缓存
|
|
2463
3100
|
await ensureCoreDeps();
|
|
2464
3101
|
|
|
2465
3102
|
try {
|