@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.
Files changed (22) hide show
  1. package/opencode-extension/skills/local-browser-test/SKILL.md +229 -9
  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 +642 -5
  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/package.json +1 -1
  8. package/qwen-extension/qwen-extension.json +1 -1
  9. package/qwen-extension/skills/local-browser-test/SKILL.md +229 -9
  10. package/qwen-extension/skills/local-browser-test/decrypt-creds.mjs +210 -0
  11. package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +642 -5
  12. package/qwen-extension/skills/local-browser-test/lib/cdp-browser-config.js +115 -0
  13. package/qwen-extension/skills/local-browser-test/lib/decrypt-passwords.js +210 -0
  14. package/qwen-extension/skills/local-browser-test/lib/shared-deps.js +2 -2
  15. package/scripts/lib/dev-browser-shortcut.js +230 -0
  16. package/scripts/postinstall.js +5 -0
  17. package/standard-skill/local-browser-test/SKILL.md +229 -9
  18. package/standard-skill/local-browser-test/decrypt-creds.mjs +210 -0
  19. package/standard-skill/local-browser-test/lib/browser-engine.js +642 -5
  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
@@ -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: passed,
2409
- failedSteps: failed,
2410
- passed: failed === 0,
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. observe/act/assert 主链路只需要 puppeteer-core
3099
+ // 2. 安装主链路依赖(puppeteer-core + sql.js)到共享缓存
2463
3100
  await ensureCoreDeps();
2464
3101
 
2465
3102
  try {