@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.
Files changed (23) hide show
  1. package/opencode-extension/skills/local-browser-test/SKILL.md +253 -8
  2. package/opencode-extension/skills/local-browser-test/decrypt-creds.mjs +210 -0
  3. package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +701 -17
  4. package/opencode-extension/skills/local-browser-test/lib/cdp-browser-config.js +115 -0
  5. package/opencode-extension/skills/local-browser-test/lib/decrypt-passwords.js +210 -0
  6. package/opencode-extension/skills/local-browser-test/lib/shared-deps.js +2 -2
  7. package/opencode-extension/skills/local-browser-test/local-browser-executor.js +50 -3
  8. package/package.json +1 -1
  9. package/qwen-extension/qwen-extension.json +1 -1
  10. package/qwen-extension/skills/local-browser-test/SKILL.md +253 -8
  11. package/qwen-extension/skills/local-browser-test/decrypt-creds.mjs +210 -0
  12. package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +701 -17
  13. package/qwen-extension/skills/local-browser-test/lib/cdp-browser-config.js +115 -0
  14. package/qwen-extension/skills/local-browser-test/lib/decrypt-passwords.js +210 -0
  15. package/qwen-extension/skills/local-browser-test/lib/shared-deps.js +2 -2
  16. package/qwen-extension/skills/local-browser-test/local-browser-executor.js +50 -3
  17. package/standard-skill/local-browser-test/SKILL.md +253 -8
  18. package/standard-skill/local-browser-test/decrypt-creds.mjs +210 -0
  19. package/standard-skill/local-browser-test/lib/browser-engine.js +701 -17
  20. package/standard-skill/local-browser-test/lib/cdp-browser-config.js +115 -0
  21. package/standard-skill/local-browser-test/lib/decrypt-passwords.js +210 -0
  22. package/standard-skill/local-browser-test/lib/shared-deps.js +2 -2
  23. 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: sr?.querySelector('body')?.innerText?.substring(0, 80) || ''
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
- if (wujieInfo.shadowBodyText) {
103
- process.stderr.write(`[wujie] shadow body preview: "${wujieInfo.shadowBodyText}"\n`);
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 hit = interactive.find((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected));
454
- if (hit) return hit;
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
- return all.find((el) => getText(el).includes(expected)) || null;
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
- 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)
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
- // setup 阶段:frame 预定位
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: passed,
2362
- failedSteps: failed,
2363
- passed: failed === 0,
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. observe/act/assert 主链路只需要 puppeteer-core
3099
+ // 2. 安装主链路依赖(puppeteer-core + sql.js)到共享缓存
2416
3100
  await ensureCoreDeps();
2417
3101
 
2418
3102
  try {