@aiscene/aiserver 1.8.2 → 1.8.4

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 (82) hide show
  1. package/dist/api/callback.d.ts.map +1 -1
  2. package/dist/api/callback.js +5 -0
  3. package/dist/api/callback.js.map +1 -1
  4. package/dist/api/device-api.d.ts.map +1 -1
  5. package/dist/api/device-api.js +9 -2
  6. package/dist/api/device-api.js.map +1 -1
  7. package/dist/api/task-api.d.ts.map +1 -1
  8. package/dist/api/task-api.js +6 -1
  9. package/dist/api/task-api.js.map +1 -1
  10. package/dist/auth/browser-auth.d.ts +4 -0
  11. package/dist/auth/browser-auth.d.ts.map +1 -0
  12. package/dist/auth/browser-auth.js +59 -0
  13. package/dist/auth/browser-auth.js.map +1 -0
  14. package/dist/auth/node-auth.d.ts +27 -0
  15. package/dist/auth/node-auth.d.ts.map +1 -0
  16. package/dist/auth/node-auth.js +132 -0
  17. package/dist/auth/node-auth.js.map +1 -0
  18. package/dist/config/cli.d.ts +12 -0
  19. package/dist/config/cli.d.ts.map +1 -1
  20. package/dist/config/cli.js +59 -2
  21. package/dist/config/cli.js.map +1 -1
  22. package/dist/config/index.d.ts.map +1 -1
  23. package/dist/config/index.js +32 -10
  24. package/dist/config/index.js.map +1 -1
  25. package/dist/config/schema.d.ts +3 -0
  26. package/dist/config/schema.d.ts.map +1 -1
  27. package/dist/core/types.d.ts +15 -0
  28. package/dist/core/types.d.ts.map +1 -1
  29. package/dist/debug/types.d.ts +69 -1
  30. package/dist/debug/types.d.ts.map +1 -1
  31. package/dist/debug/web-screencast.d.ts.map +1 -1
  32. package/dist/debug/web-screencast.js +0 -6
  33. package/dist/debug/web-screencast.js.map +1 -1
  34. package/dist/debug/websocket-server.d.ts +15 -3
  35. package/dist/debug/websocket-server.d.ts.map +1 -1
  36. package/dist/debug/websocket-server.js +674 -103
  37. package/dist/debug/websocket-server.js.map +1 -1
  38. package/dist/device/heartbeat.d.ts.map +1 -1
  39. package/dist/device/heartbeat.js +9 -2
  40. package/dist/device/heartbeat.js.map +1 -1
  41. package/dist/executor/action-executor.d.ts.map +1 -1
  42. package/dist/executor/action-executor.js +6 -0
  43. package/dist/executor/action-executor.js.map +1 -1
  44. package/dist/executor/android-executor.d.ts +3 -0
  45. package/dist/executor/android-executor.d.ts.map +1 -1
  46. package/dist/executor/android-executor.js +51 -0
  47. package/dist/executor/android-executor.js.map +1 -1
  48. package/dist/executor/cli-executor.d.ts +3 -1
  49. package/dist/executor/cli-executor.d.ts.map +1 -1
  50. package/dist/executor/cli-executor.js +59 -77
  51. package/dist/executor/cli-executor.js.map +1 -1
  52. package/dist/executor/ios-executor.d.ts.map +1 -1
  53. package/dist/executor/ios-executor.js +4 -0
  54. package/dist/executor/ios-executor.js.map +1 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +79 -27
  57. package/dist/index.js.map +1 -1
  58. package/dist/login/android-login-context.d.ts +1 -0
  59. package/dist/login/android-login-context.d.ts.map +1 -1
  60. package/dist/login/android-login-context.js +78 -13
  61. package/dist/login/android-login-context.js.map +1 -1
  62. package/dist/node/service.d.ts.map +1 -1
  63. package/dist/node/service.js +13 -3
  64. package/dist/node/service.js.map +1 -1
  65. package/dist/recorder/web-login-replay.d.ts.map +1 -1
  66. package/dist/recorder/web-login-replay.js +1 -0
  67. package/dist/recorder/web-login-replay.js.map +1 -1
  68. package/dist/task/execution-profile.d.ts +32 -0
  69. package/dist/task/execution-profile.d.ts.map +1 -0
  70. package/dist/task/execution-profile.js +227 -0
  71. package/dist/task/execution-profile.js.map +1 -0
  72. package/dist/task/scheduler.d.ts +1 -0
  73. package/dist/task/scheduler.d.ts.map +1 -1
  74. package/dist/task/scheduler.js +28 -27
  75. package/dist/task/scheduler.js.map +1 -1
  76. package/dist/task/types.js +1 -0
  77. package/dist/task/types.js.map +1 -1
  78. package/dist/web/server.d.ts +6 -1
  79. package/dist/web/server.d.ts.map +1 -1
  80. package/dist/web/server.js +141 -9
  81. package/dist/web/server.js.map +1 -1
  82. package/package.json +2 -2
@@ -41,6 +41,7 @@ export class DebugWebSocketServer {
41
41
  config;
42
42
  port;
43
43
  isStarted = false;
44
+ activeBatchSessionIds = new Set();
44
45
  // 任务日志订阅: taskId -> Set of WebSocket clients
45
46
  taskLogSubscribers = new Map();
46
47
  // 记录客户端订阅的所有任务(用于断开时清理)
@@ -258,6 +259,13 @@ export class DebugWebSocketServer {
258
259
  }
259
260
  for (const sessionId of sessionManager.findByClient(ws)) {
260
261
  const s = sessionManager.get(sessionId);
262
+ // 本地批量执行已经交给 AIServer 后台顺序跑,前端页面断开不应中断执行。
263
+ if (this.activeBatchSessionIds.has(sessionId)) {
264
+ if (s)
265
+ s.client = undefined;
266
+ logger.info(`[BatchAction] Client disconnected, keep active batch session running: ${sessionId}`);
267
+ continue;
268
+ }
261
269
  // Web 调试会话:客户端断开不销毁浏览器/投屏,仅解绑 client,由 TTL 在 30 分钟无活动时自动回收
262
270
  if (s && s.platform === 'web' && this.webSessionsActivity.has(sessionId)) {
263
271
  s.client = undefined;
@@ -295,6 +303,12 @@ export class DebugWebSocketServer {
295
303
  case 'execute_web_action':
296
304
  await this.handleExecuteWebAction(ws, message);
297
305
  break;
306
+ case 'batch_action':
307
+ await this.handleBatchAction(ws, message);
308
+ break;
309
+ case 'stop_batch':
310
+ await this.handleStopBatch(ws, message);
311
+ break;
298
312
  case 'start_screencast':
299
313
  await this.handleStartScreencast(ws, message);
300
314
  break;
@@ -478,10 +492,10 @@ export class DebugWebSocketServer {
478
492
  }
479
493
  }
480
494
  /**
481
- * Web 调试主进程内嵌执行(支持 sessionId 复用 + 浏览器/投屏保留 + 30 分钟 TTL)
495
+ * Web 调试主进程内嵌执行
482
496
  * - 首次请求:懒启动浏览器 + PlaywrightAgent + 自动投屏
483
497
  * - 复用请求:跳过初始化,直接执行 naturalLanguage
484
- * - 执行完不关浏览器,由 startWebSessionGc 按 TTL 自动回收
498
+ * - 执行完成后自动关闭浏览器,释放资源
485
499
  */
486
500
  async handleStartDebugWeb(ws, request) {
487
501
  // 1) 决定 sessionId(客户端传了就用以复用)
@@ -597,11 +611,19 @@ export class DebugWebSocketServer {
597
611
  const contextOptions = {
598
612
  viewport: request.viewport || { width: 1280, height: 800 },
599
613
  };
600
- const acquired = await webBrowserPool.acquireContext(contextOptions);
601
- browser = acquired.browser;
602
- context = acquired.context;
614
+ browser = await playwright.chromium.launch({ headless: false });
615
+ context = await browser.newContext(contextOptions);
603
616
  page = await context.newPage();
604
- releaseBrowser = acquired.release;
617
+ releaseBrowser = async () => {
618
+ try {
619
+ await context.close();
620
+ }
621
+ catch { /* ignore */ }
622
+ try {
623
+ await browser.close();
624
+ }
625
+ catch { /* ignore */ }
626
+ };
605
627
  }
606
628
  session.browser = browser;
607
629
  session.context = context;
@@ -636,12 +658,6 @@ export class DebugWebSocketServer {
636
658
  page,
637
659
  logPrefix: `[WebDebug ${sessionId}]`,
638
660
  });
639
- // 兼容旧版表单登录配置(仅首次启动时执行)
640
- if (request.loginConfig && request.loginConfig.loginUrl
641
- && request.loginConfig.loginUsername && request.loginConfig.loginPassword) {
642
- const loginConfig = { ...request.loginConfig, mobileMode: request.mobileMode || false };
643
- await this.performWebLogin(page, loginConfig);
644
- }
645
661
  }
646
662
  else {
647
663
  logger.info(`[WebDebug] Reusing existing session: ${sessionId}`);
@@ -660,7 +676,7 @@ export class DebugWebSocketServer {
660
676
  // 5) 若指定了 url,则导航(首启或复用,传了就 goto)
661
677
  if (request.url) {
662
678
  try {
663
- await page.goto(request.url, { waitUntil: 'networkidle' });
679
+ await this.safeWebGoto(page, request.url, `[WebDebug] Navigate to ${request.url}`);
664
680
  logger.info(`[WebDebug] Navigated to: ${request.url}`);
665
681
  }
666
682
  catch (gotoErr) {
@@ -697,7 +713,7 @@ export class DebugWebSocketServer {
697
713
  logger.info(`[WebDebug] report uploaded: reportUrl=${reportUrl || 'null'}, reportFile=${reportFile}`);
698
714
  const success = !execError;
699
715
  sessionManager.updateStatus(sessionId, success ? 'completed' : 'failed');
700
- // ★ 关键:不调用 closeWebSession(),浏览器与投屏完全保留,等 TTL 自然回收
716
+ // 执行完成后自动关闭浏览器,释放资源
701
717
  this.sendMessage(ws, {
702
718
  type: 'debug_completed',
703
719
  sessionId,
@@ -705,6 +721,12 @@ export class DebugWebSocketServer {
705
721
  reportUrl,
706
722
  errorMessage: execError,
707
723
  });
724
+ try {
725
+ await this.closeWebSession(sessionId);
726
+ }
727
+ catch (closeErr) {
728
+ logger.warn(`[WebDebug] Auto close browser failed: ${closeErr.message}`);
729
+ }
708
730
  }
709
731
  catch (error) {
710
732
  const msg = error.message;
@@ -712,7 +734,13 @@ export class DebugWebSocketServer {
712
734
  sessionManager.updateStatus(sessionId, 'failed');
713
735
  this.sendMessage(ws, { type: 'debug_error', sessionId, error: msg });
714
736
  this.sendMessage(ws, { type: 'debug_completed', sessionId, success: false, errorMessage: msg });
715
- // 注:致命错误也不主动关浏览器,避免破坏复用契约;如需销毁让前端发 close_web_session
737
+ // 致命错误也自动关闭浏览器
738
+ try {
739
+ await this.closeWebSession(sessionId);
740
+ }
741
+ catch (closeErr) {
742
+ logger.warn(`[WebDebug] Auto close browser (fatal) failed: ${closeErr.message}`);
743
+ }
716
744
  }
717
745
  }
718
746
  /**
@@ -792,21 +820,11 @@ export class DebugWebSocketServer {
792
820
  skipAppRestart: request.skipAppRestart,
793
821
  mobileMode: request.mobileMode,
794
822
  deviceName: request.deviceName,
823
+ loginContext: request.loginContext,
795
824
  // 环境代理配置
796
825
  proxyPort: request.proxyPort,
797
826
  // 公司代理平台账号
798
827
  proxyAccount: request.proxyAccount,
799
- // 添加登录配置
800
- ...(request.loginConfig && {
801
- loginUrl: request.loginConfig.loginUrl,
802
- loginUsername: request.loginConfig.loginUsername,
803
- loginPassword: request.loginConfig.loginPassword,
804
- usernameSelector: request.loginConfig.usernameSelector,
805
- passwordSelector: request.loginConfig.passwordSelector,
806
- loginButtonSelector: request.loginConfig.loginButtonSelector,
807
- loginModeSelector: request.loginConfig.loginModeSelector,
808
- agreementSelector: request.loginConfig.agreementSelector,
809
- }),
810
828
  };
811
829
  // 注意:proxy-service.jd.com 的规则由前端(浏览器端)设置,后端只负责设备代理 + 抓包
812
830
  try {
@@ -919,21 +937,11 @@ export class DebugWebSocketServer {
919
937
  nodeId: this.config.task.nodeId,
920
938
  modelConfig: fullModelConfig,
921
939
  skipAppRestart: request.skipAppRestart,
940
+ loginContext: request.loginContext,
922
941
  // 环境代理配置
923
942
  proxyPort: request.proxyPort,
924
943
  // 公司代理平台账号
925
944
  proxyAccount: request.proxyAccount,
926
- // 添加登录配置
927
- ...(request.loginConfig && {
928
- loginUrl: request.loginConfig.loginUrl,
929
- loginUsername: request.loginConfig.loginUsername,
930
- loginPassword: request.loginConfig.loginPassword,
931
- usernameSelector: request.loginConfig.usernameSelector,
932
- passwordSelector: request.loginConfig.passwordSelector,
933
- loginButtonSelector: request.loginConfig.loginButtonSelector,
934
- loginModeSelector: request.loginConfig.loginModeSelector,
935
- agreementSelector: request.loginConfig.agreementSelector,
936
- }),
937
945
  };
938
946
  // 注意:proxy-service.jd.com 的规则由前端(浏览器端)设置,后端只负责设备代理 + 抓包
939
947
  // 始终启动请求抓包
@@ -1514,58 +1522,79 @@ export class DebugWebSocketServer {
1514
1522
  }
1515
1523
  }
1516
1524
  // ==================== Execute Web Action (Playwright + Midscene, with screencast) ====================
1517
- async performWebLogin(page, loginConfig) {
1518
- if (!loginConfig.loginUrl || !loginConfig.loginUsername || !loginConfig.loginPassword) {
1519
- logger.warn('Login credentials not provided, skipping login');
1520
- return;
1525
+ normalizeWebUrl(value) {
1526
+ if (!value)
1527
+ return '';
1528
+ try {
1529
+ const parsed = new URL(value);
1530
+ parsed.hash = '';
1531
+ return parsed.toString().replace(/\/$/, '');
1532
+ }
1533
+ catch {
1534
+ return String(value).split('#')[0].replace(/\/$/, '');
1521
1535
  }
1522
- logger.info(`Performing web login at: ${loginConfig.loginUrl}`);
1536
+ }
1537
+ getCurrentWebUrl(page) {
1523
1538
  try {
1524
- // Navigate to login page
1525
- await page.goto(loginConfig.loginUrl, { waitUntil: 'networkidle' });
1526
- logger.info(`Navigated to login page: ${loginConfig.loginUrl}`);
1527
- // Wait for page to load
1528
- await new Promise(resolve => setTimeout(resolve, loginConfig.mobileMode ? 3000 : 2000));
1529
- // Click "账号密码登录" button first
1530
- logger.info('Clicking account password login button');
1531
- await page.click('span.J_ping.planBLogin:has-text("账号密码登录")');
1532
- // Wait for login form to appear
1533
- await new Promise(resolve => setTimeout(resolve, loginConfig.mobileMode ? 2000 : 1000));
1534
- // Fill username
1535
- const usernameSelector = loginConfig.usernameSelector || '#username';
1536
- logger.info(`Filling username with selector: ${usernameSelector}`);
1537
- await page.fill(usernameSelector, loginConfig.loginUsername);
1538
- // Fill password
1539
- const passwordSelector = loginConfig.passwordSelector || '#pwd';
1540
- logger.info(`Filling password with selector: ${passwordSelector}`);
1541
- await page.fill(passwordSelector, loginConfig.loginPassword);
1542
- // Check agreement checkbox - using the specific selector
1543
- const agreementSelector = loginConfig.agreementSelector || '.policy_tip-checkbox';
1544
- logger.info(`Checking agreement with selector: ${agreementSelector}`);
1545
- await page.check(agreementSelector);
1546
- // Click login button - using the specific selector
1547
- const loginButtonSelector = loginConfig.loginButtonSelector || 'a.J_ping:has-text("同意协议并登录")';
1548
- logger.info(`Clicking login button with selector: ${loginButtonSelector}`);
1549
- await page.click(loginButtonSelector);
1550
- // Wait for login to complete
1551
- await new Promise(resolve => setTimeout(resolve, loginConfig.mobileMode ? 8000 : 5000));
1552
- // Check if login was successful
1553
- try {
1554
- const currentUrl = page.url();
1555
- if (currentUrl !== loginConfig.loginUrl && !currentUrl.includes('login')) {
1556
- logger.info('Login appears to be successful');
1557
- }
1558
- else {
1559
- logger.warn('Still on login page, login may have failed');
1560
- }
1561
- }
1562
- catch (e) {
1563
- logger.warn('Could not verify login status');
1564
- }
1539
+ return typeof page?.url === 'function' ? page.url() : '';
1540
+ }
1541
+ catch {
1542
+ return '';
1543
+ }
1544
+ }
1545
+ async waitForWebPageStable(page) {
1546
+ await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => undefined);
1547
+ await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => undefined);
1548
+ }
1549
+ async safeWebGoto(page, url, label) {
1550
+ const targetUrl = this.normalizeWebUrl(url);
1551
+ const currentUrl = this.normalizeWebUrl(this.getCurrentWebUrl(page));
1552
+ if (currentUrl && targetUrl && currentUrl === targetUrl) {
1553
+ logger.info(`${label}: already at target url, skip duplicate goto`);
1554
+ await this.waitForWebPageStable(page);
1555
+ return null;
1556
+ }
1557
+ try {
1558
+ logger.info(`${label}: ${url}`);
1559
+ const result = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
1560
+ await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => undefined);
1561
+ return result;
1565
1562
  }
1566
1563
  catch (error) {
1567
- logger.error(`Web login failed: ${error.message}`);
1568
- throw new Error(`Web login failed: ${error.message}`);
1564
+ const message = error.message || '';
1565
+ const latestUrl = this.normalizeWebUrl(this.getCurrentWebUrl(page));
1566
+ // 导航被中断/中止时,检查页面是否已到达目标 URL,若已到达则继续执行
1567
+ const isAborted = message.includes('ERR_ABORTED') || message.includes('is interrupted by another navigation');
1568
+ if (isAborted) {
1569
+ // 短暂等待让页面重定向/跳转完成
1570
+ await new Promise((r) => setTimeout(r, 1500));
1571
+ const settledUrl = this.normalizeWebUrl(this.getCurrentWebUrl(page));
1572
+ if (settledUrl && settledUrl === targetUrl) {
1573
+ logger.warn(`${label}: navigation aborted but page reached target url, continue`);
1574
+ await this.waitForWebPageStable(page);
1575
+ return null;
1576
+ }
1577
+ // URL 不完全匹配但导航被中止,可能是目标页面发生了重定向(登录回调等),
1578
+ // 尝试重新导航一次
1579
+ logger.warn(`${label}: navigation aborted, current=${settledUrl}, retrying goto`);
1580
+ try {
1581
+ const retryResult = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1582
+ await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => undefined);
1583
+ return retryResult;
1584
+ }
1585
+ catch (retryError) {
1586
+ const retryMessage = retryError.message || '';
1587
+ const finalUrl = this.normalizeWebUrl(this.getCurrentWebUrl(page));
1588
+ // 重试后仍报错,但只要页面不在 about:blank 就继续
1589
+ if (finalUrl && finalUrl !== 'about:blank') {
1590
+ logger.warn(`${label}: retry goto also failed (${retryMessage.substring(0, 80)}), but page has content at ${finalUrl}, continue`);
1591
+ await this.waitForWebPageStable(page);
1592
+ return null;
1593
+ }
1594
+ throw retryError;
1595
+ }
1596
+ }
1597
+ throw error;
1569
1598
  }
1570
1599
  }
1571
1600
  async handleExecuteWebAction(ws, request) {
@@ -1642,15 +1671,23 @@ export class DebugWebSocketServer {
1642
1671
  };
1643
1672
  }
1644
1673
  else {
1645
- // 桌面模式:走浏览器池,复用已 launch Chromium
1674
+ // 桌面模式:每次执行独立启动 Chromium,结束后真正关闭,避免残留 Chrome 进程。
1646
1675
  const contextOptions = {
1647
1676
  viewport: request.viewport || { width: 1280, height: 800 },
1648
1677
  };
1649
- const acquired = await webBrowserPool.acquireContext(contextOptions);
1650
- browser = acquired.browser;
1651
- context = acquired.context;
1678
+ browser = await playwright.chromium.launch({ headless: false });
1679
+ context = await browser.newContext(contextOptions);
1652
1680
  page = await context.newPage();
1653
- releaseBrowser = acquired.release;
1681
+ releaseBrowser = async () => {
1682
+ try {
1683
+ await context.close();
1684
+ }
1685
+ catch { /* ignore */ }
1686
+ try {
1687
+ await browser.close();
1688
+ }
1689
+ catch { /* ignore */ }
1690
+ };
1654
1691
  }
1655
1692
  // mobileMode 下注入脚本,将 touchstart/touchmove/wheel 事件监听器设为 passive:false
1656
1693
  // 修复部分移动端网页在 passive listener 中调用 preventDefault 导致点击失效的问题
@@ -1714,20 +1751,25 @@ export class DebugWebSocketServer {
1714
1751
  // 取出 agent / page
1715
1752
  const agent = session.webAgent;
1716
1753
  const page = session.page;
1754
+ // 执行前检查浏览器和页面是否仍然存活,防止在已崩溃的浏览器上操作导致不明错误
1755
+ const browserRef = session.browser;
1756
+ if (browserRef && typeof browserRef.isConnected === 'function' && !browserRef.isConnected()) {
1757
+ throw new Error('Browser has been disconnected before action execution. The browser process may have crashed or been killed.');
1758
+ }
1759
+ if (page && typeof page.isClosed === 'function' && page.isClosed()) {
1760
+ throw new Error('Page has been closed before action execution. The browser context may have been terminated.');
1761
+ }
1717
1762
  // 刷新 web session 活跃时间,避免 TTL 误杀
1718
1763
  this.touchWebSession(sessionId);
1764
+ // 标记 web action 正在执行中,防止 TTL GC 或并发路径误关浏览器
1765
+ session.webActionRunning = true;
1719
1766
  await executeWebLogin({
1720
1767
  source: request,
1721
1768
  page,
1722
1769
  logPrefix: `[WebAction ${sessionId}]`,
1723
1770
  });
1724
- // 兼容旧版表单登录配置
1725
- if (request.loginConfig && request.loginConfig.loginUrl && request.loginConfig.loginUsername && request.loginConfig.loginPassword) {
1726
- const loginConfig = {
1727
- ...request.loginConfig,
1728
- mobileMode: request.mobileMode || false
1729
- };
1730
- await this.performWebLogin(page, loginConfig);
1771
+ if (request.url && request.actionType !== 'goto') {
1772
+ await this.safeWebGoto(page, request.url, `[WebAction] Navigate before action`);
1731
1773
  }
1732
1774
  // ===== 周期 dump(与移动端 handleExecuteAction 行为一致) =====
1733
1775
  const sendDump = (dump) => {
@@ -1765,6 +1807,8 @@ export class DebugWebSocketServer {
1765
1807
  const intervalDump = agent.dumpDataString({ inlineScreenshots: false });
1766
1808
  sendDump(intervalDump);
1767
1809
  }
1810
+ // 每次 dump 周期刷新活跃时间,防止长时间 aiAct 操作期间被 TTL GC 误杀
1811
+ this.touchWebSession(sessionId);
1768
1812
  }
1769
1813
  catch (dumpError) {
1770
1814
  logger.warn(`[WebAction] interval dump failed: ${dumpError.message}`);
@@ -1787,7 +1831,7 @@ export class DebugWebSocketServer {
1787
1831
  case 'goto': {
1788
1832
  if (!request.url)
1789
1833
  throw new Error('goto requires url parameter');
1790
- result = await page.goto(request.url, { waitUntil: 'networkidle' });
1834
+ result = await this.safeWebGoto(page, request.url, `[WebAction] goto`);
1791
1835
  break;
1792
1836
  }
1793
1837
  case 'aiAction': {
@@ -1833,11 +1877,6 @@ export class DebugWebSocketServer {
1833
1877
  const code = request.actionParams?.code || request.code || '';
1834
1878
  if (!code)
1835
1879
  throw new Error('runCode requires actionParams.code');
1836
- // 如果提供了URL,先导航到指定页面
1837
- if (request.url) {
1838
- logger.info(`[WebAction] Navigating to URL before runCode: ${request.url}`);
1839
- await page.goto(request.url, { waitUntil: 'networkidle' });
1840
- }
1841
1880
  // 行号探针:与真机 runCode 一致,通过 instrumentCode 在每条语句前插入 __lineCallback(line),
1842
1881
  // 推送 type='execution_line' 给前端,使 web 与真机调试体验一致
1843
1882
  const onExecutionLine = (line) => {
@@ -2036,6 +2075,10 @@ export class DebugWebSocketServer {
2036
2075
  });
2037
2076
  }
2038
2077
  finally {
2078
+ // 清除执行中标记,允许后续关闭浏览器
2079
+ if (session) {
2080
+ session.webActionRunning = false;
2081
+ }
2039
2082
  // 不管成功还是失败,web 调试结束后必须关闭浏览器
2040
2083
  try {
2041
2084
  await this.closeWebSession(sessionId);
@@ -2045,6 +2088,504 @@ export class DebugWebSocketServer {
2045
2088
  }
2046
2089
  }
2047
2090
  }
2091
+ // ==================== Batch Action (逐条执行一批用例,支持 web / android / ios) ====================
2092
+ batchAbortControllers = new Map();
2093
+ async handleBatchAction(ws, request) {
2094
+ const { batchId, cases, platform } = request;
2095
+ if (!batchId || !Array.isArray(cases) || cases.length === 0) {
2096
+ this.sendError(ws, 'batch_action requires batchId and non-empty cases array');
2097
+ return;
2098
+ }
2099
+ // 创建 AbortController 用于取消批次
2100
+ const abortController = new AbortController();
2101
+ this.batchAbortControllers.set(batchId, abortController);
2102
+ // 批次级别配置
2103
+ const batchLoginContext = request.loginContext;
2104
+ const batchModelConfig = request.modelConfig;
2105
+ // 通知前端:批次开始
2106
+ this.sendMessage(ws, {
2107
+ type: 'batch_started',
2108
+ batchId,
2109
+ totalCases: cases.length,
2110
+ platform,
2111
+ });
2112
+ const results = [];
2113
+ // Web 相关状态
2114
+ let browserLaunched = false;
2115
+ let sharedSessionId = '';
2116
+ try {
2117
+ for (let i = 0; i < cases.length; i++) {
2118
+ // 检查是否已取消
2119
+ if (abortController.signal.aborted) {
2120
+ logger.info(`[BatchAction] Batch ${batchId} aborted, skipping remaining ${cases.length - i} cases`);
2121
+ break;
2122
+ }
2123
+ const tc = cases[i];
2124
+ const caseLabel = `[Batch ${batchId} case ${i + 1}/${cases.length}] ${tc.id}${tc.title ? ` - ${tc.title}` : ''}`;
2125
+ logger.info(`${caseLabel}: starting (platform=${platform})`);
2126
+ // 通知前端:当前用例开始
2127
+ this.sendMessage(ws, {
2128
+ type: 'batch_case_started',
2129
+ batchId,
2130
+ caseId: tc.id,
2131
+ caseIndex: i,
2132
+ totalCases: cases.length,
2133
+ });
2134
+ await this.postBatchCallback(request, `/test-batches/${encodeURIComponent(batchId)}/items/${encodeURIComponent(tc.id)}`, {
2135
+ status: 'running',
2136
+ caseIndex: i,
2137
+ totalCases: cases.length,
2138
+ });
2139
+ let caseSuccess = false;
2140
+ let caseError;
2141
+ let caseReportUrl;
2142
+ let caseCaptureDataUrl;
2143
+ let caseSessionId;
2144
+ const caseStartedAt = Date.now();
2145
+ try {
2146
+ if (platform === 'web') {
2147
+ // ============ Web 路径 ============
2148
+ const result = await this.executeBatchWebCase(ws, request, tc, i, caseLabel, batchLoginContext, batchModelConfig, sharedSessionId, browserLaunched);
2149
+ caseSuccess = result.success;
2150
+ caseError = result.error;
2151
+ caseReportUrl = result.reportUrl;
2152
+ caseCaptureDataUrl = result.captureDataUrl;
2153
+ caseSessionId = result.sessionId;
2154
+ sharedSessionId = result.sessionId;
2155
+ browserLaunched = result.browserLaunched;
2156
+ }
2157
+ else {
2158
+ // ============ App (Android/iOS) 路径 ============
2159
+ const result = await this.executeBatchAppCase(ws, request, tc, i, caseLabel, batchLoginContext, batchModelConfig);
2160
+ caseSuccess = result.success;
2161
+ caseError = result.error;
2162
+ caseReportUrl = result.reportUrl;
2163
+ caseCaptureDataUrl = result.captureDataUrl;
2164
+ caseSessionId = result.sessionId;
2165
+ }
2166
+ if (caseSuccess) {
2167
+ logger.info(`${caseLabel}: completed successfully`);
2168
+ }
2169
+ }
2170
+ catch (err) {
2171
+ caseError = err.message;
2172
+ caseSuccess = false;
2173
+ logger.warn(`${caseLabel}: failed - ${caseError}`);
2174
+ }
2175
+ const caseResult = {
2176
+ id: tc.id,
2177
+ success: caseSuccess,
2178
+ error: caseError,
2179
+ reportUrl: caseReportUrl,
2180
+ captureDataUrl: caseCaptureDataUrl,
2181
+ };
2182
+ results.push(caseResult);
2183
+ // 通知前端:当前用例完成
2184
+ this.sendMessage(ws, {
2185
+ type: 'batch_case_completed',
2186
+ batchId,
2187
+ caseId: tc.id,
2188
+ caseIndex: i,
2189
+ totalCases: cases.length,
2190
+ success: caseSuccess,
2191
+ error: caseError,
2192
+ reportUrl: caseReportUrl,
2193
+ captureDataUrl: caseCaptureDataUrl,
2194
+ });
2195
+ await this.postBatchCallback(request, `/test-batches/${encodeURIComponent(batchId)}/items/${encodeURIComponent(tc.id)}`, {
2196
+ status: caseSuccess ? 'completed' : 'failed',
2197
+ success: caseSuccess,
2198
+ error: caseError,
2199
+ reportUrl: caseReportUrl,
2200
+ captureDataUrl: caseCaptureDataUrl,
2201
+ sessionId: caseSessionId,
2202
+ deviceId: tc.deviceId || request.deviceId,
2203
+ durationMs: Date.now() - caseStartedAt,
2204
+ });
2205
+ }
2206
+ }
2207
+ finally {
2208
+ // 清理 AbortController
2209
+ this.batchAbortControllers.delete(batchId);
2210
+ // 关闭浏览器(仅 web)
2211
+ if (platform === 'web' && sharedSessionId && browserLaunched) {
2212
+ try {
2213
+ await this.closeWebSession(sharedSessionId);
2214
+ }
2215
+ catch { /* ignore */ }
2216
+ }
2217
+ // 通知前端:批次完成
2218
+ this.sendMessage(ws, {
2219
+ type: 'batch_completed',
2220
+ batchId,
2221
+ totalCases: cases.length,
2222
+ completedCases: results.length,
2223
+ results,
2224
+ });
2225
+ await this.postBatchCallback(request, `/test-batches/${encodeURIComponent(batchId)}/finalize`, {
2226
+ status: 'completed',
2227
+ totalCases: cases.length,
2228
+ completedCases: results.length,
2229
+ results,
2230
+ });
2231
+ }
2232
+ }
2233
+ /** 批次执行 - 单条 Web 用例 */
2234
+ async executeBatchWebCase(ws, request, tc, caseIndex, caseLabel, batchLoginContext, batchModelConfig, sharedSessionId, browserLaunched) {
2235
+ // 构造单条 WebActionExecuteRequest
2236
+ const webActionRequest = {
2237
+ type: 'execute_web_action',
2238
+ actionType: tc.actionType || 'aiAction',
2239
+ url: tc.url || request.url,
2240
+ naturalLanguage: tc.naturalLanguage,
2241
+ actionParams: tc.actionParams,
2242
+ sessionId: sharedSessionId || undefined,
2243
+ mobileMode: request.mobileMode,
2244
+ deviceName: request.deviceName,
2245
+ viewport: request.viewport,
2246
+ headless: request.headless,
2247
+ screencast: request.screencast,
2248
+ modelConfig: batchModelConfig,
2249
+ loginContext: tc.loginContext || batchLoginContext,
2250
+ };
2251
+ let caseSuccess = false;
2252
+ let caseError;
2253
+ let caseReportUrl;
2254
+ let caseCaptureDataUrl;
2255
+ // 如果 freshBrowserPerCase 或首次执行,需要完整启动浏览器
2256
+ if (!browserLaunched || request.freshBrowserPerCase) {
2257
+ // 关闭之前的浏览器(如果有)
2258
+ if (sharedSessionId && browserLaunched) {
2259
+ try {
2260
+ await this.closeWebSession(sharedSessionId);
2261
+ }
2262
+ catch { /* ignore */ }
2263
+ }
2264
+ sharedSessionId = `batch-${request.batchId}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
2265
+ webActionRequest.sessionId = sharedSessionId;
2266
+ }
2267
+ else {
2268
+ // 复用浏览器
2269
+ webActionRequest.sessionId = sharedSessionId;
2270
+ }
2271
+ const sessionId = sharedSessionId;
2272
+ const reportGroupName = tc.title || tc.id || `batch-${request.batchId}-case-${caseIndex + 1}`;
2273
+ let session = sessionManager.get(sessionId);
2274
+ const isNewSession = !session || !session.browser;
2275
+ if (isNewSession) {
2276
+ if (!session)
2277
+ session = sessionManager.create(sessionId, ws);
2278
+ session.platform = 'web';
2279
+ // 并发上限保护
2280
+ if (this.webSessionsActivity.size >= this.webSessionMaxConcurrency) {
2281
+ throw new Error(`Web 调试会话数已达上限 (${this.webSessionMaxConcurrency}),请稍后重试`);
2282
+ }
2283
+ const playwright = await import('playwright');
2284
+ const midscene = await import('@aiscene/web/playwright');
2285
+ let browser;
2286
+ let context;
2287
+ let page;
2288
+ let releaseBrowser = null;
2289
+ if (request.mobileMode) {
2290
+ const deviceName = request.deviceName || 'iPhone 13';
2291
+ const deviceDescriptor = playwright.devices[deviceName];
2292
+ browser = await playwright.chromium.launch({ headless: false });
2293
+ if (deviceDescriptor) {
2294
+ context = await browser.newContext(deviceDescriptor);
2295
+ }
2296
+ else {
2297
+ context = await browser.newContext({
2298
+ viewport: { width: 375, height: 667 }, isMobile: true,
2299
+ hasTouch: true, deviceScaleFactor: 2,
2300
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1',
2301
+ });
2302
+ }
2303
+ page = await context.newPage();
2304
+ releaseBrowser = async () => { try {
2305
+ await context.close();
2306
+ }
2307
+ catch { /* ignore */ } try {
2308
+ await browser.close();
2309
+ }
2310
+ catch { /* ignore */ } };
2311
+ }
2312
+ else {
2313
+ browser = await playwright.chromium.launch({ headless: false });
2314
+ context = await browser.newContext({ viewport: request.viewport || { width: 1280, height: 800 } });
2315
+ page = await context.newPage();
2316
+ releaseBrowser = async () => { try {
2317
+ await context.close();
2318
+ }
2319
+ catch { /* ignore */ } try {
2320
+ await browser.close();
2321
+ }
2322
+ catch { /* ignore */ } };
2323
+ }
2324
+ if (request.mobileMode) {
2325
+ await page.addInitScript(() => {
2326
+ const origAdd = EventTarget.prototype.addEventListener;
2327
+ EventTarget.prototype.addEventListener = function (type, listener, options) {
2328
+ if (['touchstart', 'touchmove', 'wheel'].includes(type)) {
2329
+ if (typeof options === 'boolean')
2330
+ options = { capture: options, passive: false };
2331
+ else if (options && typeof options === 'object')
2332
+ options = { ...options, passive: false };
2333
+ else
2334
+ options = { passive: false };
2335
+ }
2336
+ return origAdd.call(this, type, listener, options);
2337
+ };
2338
+ });
2339
+ }
2340
+ session.browser = browser;
2341
+ session.context = context;
2342
+ session.page = page;
2343
+ session.releaseBrowser = releaseBrowser;
2344
+ session.webAgent = new midscene.PlaywrightAgent(page, {
2345
+ outputFormat: 'html-and-external-assets',
2346
+ groupName: reportGroupName,
2347
+ modelConfig: this.applyModelConfig(batchModelConfig),
2348
+ });
2349
+ this.registerWebSession(sessionId);
2350
+ browserLaunched = true;
2351
+ logger.info(`[BatchAction] Browser launched for session: ${sessionId}`);
2352
+ }
2353
+ // 确保 session 存在
2354
+ if (!session)
2355
+ throw new Error('Session not available');
2356
+ // 复用时重建 agent(每条用例独立报告)
2357
+ const midscene = await import('@aiscene/web/playwright');
2358
+ session.webAgent = new midscene.PlaywrightAgent(session.page, {
2359
+ outputFormat: 'html-and-external-assets',
2360
+ groupName: reportGroupName,
2361
+ modelConfig: this.applyModelConfig(batchModelConfig),
2362
+ });
2363
+ const agent = session.webAgent;
2364
+ const page = session.page;
2365
+ // 检查浏览器存活
2366
+ const browserRef = session.browser;
2367
+ if (browserRef && typeof browserRef.isConnected === 'function' && !browserRef.isConnected()) {
2368
+ throw new Error('Browser has been disconnected');
2369
+ }
2370
+ if (page && typeof page.isClosed === 'function' && page.isClosed()) {
2371
+ throw new Error('Page has been closed');
2372
+ }
2373
+ this.touchWebSession(sessionId);
2374
+ session.webActionRunning = true;
2375
+ // 执行登录(仅首条或 freshBrowserPerCase 时)
2376
+ const effectiveLoginContext = tc.loginContext || batchLoginContext;
2377
+ if (effectiveLoginContext && (caseIndex === 0 || request.freshBrowserPerCase || !request.loginContext)) {
2378
+ await executeWebLogin({
2379
+ source: { loginContext: effectiveLoginContext },
2380
+ page,
2381
+ logPrefix: caseLabel,
2382
+ });
2383
+ }
2384
+ // 导航到目标URL
2385
+ const targetUrl = tc.url || request.url;
2386
+ if (targetUrl && webActionRequest.actionType !== 'goto') {
2387
+ await this.safeWebGoto(page, targetUrl, `${caseLabel} Navigate`);
2388
+ }
2389
+ // 执行动作
2390
+ const actionType = tc.actionType || 'aiAction';
2391
+ if (actionType === 'aiAction' || actionType === 'aiAct') {
2392
+ const text = tc.naturalLanguage || '';
2393
+ if (!text)
2394
+ throw new Error('aiAction requires naturalLanguage');
2395
+ await agent.aiAct(text);
2396
+ }
2397
+ else if (actionType === 'goto') {
2398
+ if (!targetUrl)
2399
+ throw new Error('goto requires url');
2400
+ await this.safeWebGoto(page, targetUrl, `${caseLabel} goto`);
2401
+ }
2402
+ else if (actionType === 'runCode') {
2403
+ const code = tc.actionParams?.code || '';
2404
+ if (!code)
2405
+ throw new Error('runCode requires actionParams.code');
2406
+ const AsyncFunctionCtor = async function () { }.constructor;
2407
+ const ctx = {
2408
+ agent, page,
2409
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
2410
+ nativeSwipe: (direction, duration) => playwrightSwipe(page, { direction, duration: duration || 300 }),
2411
+ console: {
2412
+ log: (...args) => {
2413
+ sessionManager.addLog(sessionId, args.map(String).join(' '));
2414
+ this.sendMessage(ws, { type: 'log_output', sessionId, content: args.map(String).join(' ') });
2415
+ },
2416
+ },
2417
+ };
2418
+ const fn = new AsyncFunctionCtor(code);
2419
+ await fn.call(ctx);
2420
+ }
2421
+ caseSuccess = true;
2422
+ // 提取报告
2423
+ let reportFile = '';
2424
+ try {
2425
+ const sess = sessionManager.get(sessionId);
2426
+ if (sess?.webAgent && typeof sess.webAgent.reportFile === 'string') {
2427
+ reportFile = sess.webAgent.reportFile || '';
2428
+ }
2429
+ }
2430
+ catch { /* ignore */ }
2431
+ caseReportUrl = (await this.safeUploadReport(reportFile, `${request.batchId}-${tc.id}`)) ?? undefined;
2432
+ // 停止抓包
2433
+ try {
2434
+ const capturedRequests = await whistleManager.stopCapture(sessionId);
2435
+ if (capturedRequests.length > 0) {
2436
+ const uploadResult = await this.saveAndUploadCaptureData(capturedRequests, `${request.batchId}-${tc.id}`);
2437
+ if (uploadResult)
2438
+ caseCaptureDataUrl = uploadResult;
2439
+ }
2440
+ }
2441
+ catch { /* ignore */ }
2442
+ // 重置执行标记
2443
+ const sess = sessionManager.get(sessionId);
2444
+ if (sess)
2445
+ sess.webActionRunning = false;
2446
+ return { success: caseSuccess, error: caseError, reportUrl: caseReportUrl, captureDataUrl: caseCaptureDataUrl, sessionId, browserLaunched };
2447
+ }
2448
+ /** 批次执行 - 单条 App (Android/iOS) 用例 */
2449
+ async executeBatchAppCase(ws, request, tc, caseIndex, caseLabel, batchLoginContext, batchModelConfig) {
2450
+ const { platform, batchId } = request;
2451
+ const effectiveDeviceId = tc.deviceId || request.deviceId;
2452
+ const effectivePackageName = tc.packageName || request.packageName;
2453
+ const effectiveBundleId = tc.bundleId || request.bundleId;
2454
+ const effectiveLoginContext = tc.loginContext || batchLoginContext;
2455
+ const caseSessionId = `batch-${batchId}-case-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
2456
+ const session = sessionManager.create(caseSessionId, ws, effectiveDeviceId);
2457
+ this.activeBatchSessionIds.add(caseSessionId);
2458
+ const caseAbortController = new AbortController();
2459
+ session.abortController = caseAbortController;
2460
+ // 应用模型配置
2461
+ const fullModelConfig = this.applyModelConfig(batchModelConfig);
2462
+ let caseSuccess = false;
2463
+ let caseError;
2464
+ let caseReportUrl;
2465
+ let caseCaptureDataUrl;
2466
+ try {
2467
+ sessionManager.updateStatus(caseSessionId, 'running');
2468
+ // 执行 Android 登录
2469
+ if (platform === 'android') {
2470
+ await executeAndroidLogin({
2471
+ source: { loginContext: effectiveLoginContext },
2472
+ deviceId: effectiveDeviceId,
2473
+ platform,
2474
+ logPrefix: caseLabel,
2475
+ });
2476
+ }
2477
+ // 创建 Executor
2478
+ const executor = ExecutorFactory.create('action', {
2479
+ sessionId: caseSessionId,
2480
+ onLog: (msg) => {
2481
+ sessionManager.addLog(caseSessionId, msg);
2482
+ this.sendMessage(ws, { type: 'log_output', sessionId: caseSessionId, content: msg }, effectiveDeviceId);
2483
+ },
2484
+ onDump: (dump) => {
2485
+ this.sendMessage(ws, { type: 'action_dump', sessionId: caseSessionId, dump }, effectiveDeviceId);
2486
+ },
2487
+ onExecutionLine: (line) => {
2488
+ this.sendMessage(ws, { type: 'execution_line', sessionId: caseSessionId, line }, effectiveDeviceId);
2489
+ },
2490
+ });
2491
+ session.executor = executor;
2492
+ // 启动抓包
2493
+ try {
2494
+ await whistleManager.startCapture(caseSessionId, undefined, 3000, request.proxyAccount);
2495
+ logger.info(`[BatchAction] Whistle capture started: sessionId=${caseSessionId}`);
2496
+ }
2497
+ catch (captureErr) {
2498
+ logger.warn(`[BatchAction] Failed to start capture: ${captureErr.message}`);
2499
+ }
2500
+ // 构造 ExecuteConfig
2501
+ const actionType = tc.actionType || 'aiAct';
2502
+ const config = {
2503
+ type: platform,
2504
+ testName: tc.title || tc.id || `batch-${batchId}-case-${caseIndex + 1}`,
2505
+ actionType,
2506
+ naturalLanguage: tc.naturalLanguage,
2507
+ locatePrompt: tc.actionParams?.locatePrompt,
2508
+ actionParams: tc.actionParams,
2509
+ udid: effectiveDeviceId,
2510
+ packageName: effectivePackageName,
2511
+ bundleId: effectiveBundleId,
2512
+ modelConfig: fullModelConfig,
2513
+ executionId: caseSessionId,
2514
+ skipAppRestart: request.skipAppRestart,
2515
+ proxyPort: request.proxyPort,
2516
+ proxyAccount: request.proxyAccount,
2517
+ };
2518
+ // 使用 AbortSignal 监听停止
2519
+ const executePromise = executor.execute(config);
2520
+ const abortPromise = new Promise((resolve) => {
2521
+ caseAbortController.signal.addEventListener('abort', () => {
2522
+ resolve({ success: false, errorMessage: 'Action stopped by user' });
2523
+ }, { once: true });
2524
+ });
2525
+ const result = await Promise.race([executePromise, abortPromise]);
2526
+ // 清理 executor
2527
+ if (executor && typeof executor.destroy === 'function') {
2528
+ try {
2529
+ await executor.destroy();
2530
+ }
2531
+ catch { /* ignore */ }
2532
+ }
2533
+ session.executor = undefined;
2534
+ caseSuccess = result.success;
2535
+ if (!result.success && result.errorMessage)
2536
+ caseError = result.errorMessage;
2537
+ // 上传报告
2538
+ const reportFile = result.reportFile || '';
2539
+ caseReportUrl = (await this.safeUploadReport(reportFile, `${batchId}-${tc.id}`)) ?? undefined;
2540
+ // 停止抓包
2541
+ try {
2542
+ const capturedRequests = await whistleManager.stopCapture(caseSessionId);
2543
+ if (capturedRequests.length > 0) {
2544
+ const uploadResult = await this.saveAndUploadCaptureData(capturedRequests, `${batchId}-${tc.id}`);
2545
+ if (uploadResult)
2546
+ caseCaptureDataUrl = uploadResult;
2547
+ }
2548
+ }
2549
+ catch { /* ignore */ }
2550
+ }
2551
+ catch (err) {
2552
+ caseError = err.message;
2553
+ caseSuccess = false;
2554
+ // 清理 executor
2555
+ if (session.executor && typeof session.executor.destroy === 'function') {
2556
+ try {
2557
+ await session.executor.destroy();
2558
+ }
2559
+ catch { /* ignore */ }
2560
+ }
2561
+ session.executor = undefined;
2562
+ }
2563
+ sessionManager.updateStatus(caseSessionId, caseSuccess ? 'completed' : 'failed');
2564
+ this.activeBatchSessionIds.delete(caseSessionId);
2565
+ try {
2566
+ await this.cleanupSession(caseSessionId);
2567
+ }
2568
+ catch (cleanupError) {
2569
+ logger.warn(`[BatchAction] Session cleanup failed: sessionId=${caseSessionId}, error=${cleanupError.message}`);
2570
+ }
2571
+ return { success: caseSuccess, error: caseError, reportUrl: caseReportUrl, captureDataUrl: caseCaptureDataUrl, sessionId: caseSessionId };
2572
+ }
2573
+ // ==================== Stop Batch ====================
2574
+ async handleStopBatch(ws, request) {
2575
+ const { batchId } = request;
2576
+ const abortController = this.batchAbortControllers.get(batchId);
2577
+ if (abortController) {
2578
+ abortController.abort();
2579
+ this.batchAbortControllers.delete(batchId);
2580
+ this.sendMessage(ws, { type: 'batch_stopped', batchId });
2581
+ }
2582
+ else {
2583
+ this.sendMessage(ws, {
2584
+ type: 'error',
2585
+ message: `No active batch found with id: ${batchId}`,
2586
+ });
2587
+ }
2588
+ }
2048
2589
  // ==================== Start Screencast (for existing web session) ====================
2049
2590
  async handleStartScreencast(ws, request) {
2050
2591
  const session = sessionManager.get(request.sessionId);
@@ -2094,6 +2635,11 @@ export class DebugWebSocketServer {
2094
2635
  const session = sessionManager.get(sessionId);
2095
2636
  if (!session || !session.browser)
2096
2637
  return;
2638
+ // 如果 web action 正在执行中,跳过关闭,防止 TTL GC 或其他并发路径误杀浏览器
2639
+ if (session.webActionRunning) {
2640
+ logger.warn(`[WebSession] Session ${sessionId} has running web action, skipping close to avoid race condition`);
2641
+ return;
2642
+ }
2097
2643
  // 停止周期 dump
2098
2644
  if (session.dumpIntervalId) {
2099
2645
  clearInterval(session.dumpIntervalId);
@@ -2378,6 +2924,31 @@ export class DebugWebSocketServer {
2378
2924
  logger.info(`[ModelConfig] Model config applied successfully`);
2379
2925
  return result;
2380
2926
  }
2927
+ async postBatchCallback(request, pathName, payload) {
2928
+ const baseUrl = request.callback?.baseUrl?.trim();
2929
+ if (!baseUrl)
2930
+ return;
2931
+ try {
2932
+ const relativePath = pathName.replace(/^\/+/, '');
2933
+ const url = new URL(relativePath, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
2934
+ const headers = { 'Content-Type': 'application/json' };
2935
+ const token = request.callback?.token?.trim();
2936
+ if (token)
2937
+ headers.Authorization = token.startsWith('Bearer ') ? token : `Bearer ${token}`;
2938
+ const response = await fetch(url.toString(), {
2939
+ method: 'POST',
2940
+ headers,
2941
+ body: JSON.stringify(payload),
2942
+ });
2943
+ if (!response.ok) {
2944
+ const text = await response.text().catch(() => '');
2945
+ logger.warn(`[BatchAction] Callback failed: ${response.status} ${response.statusText}, path=${pathName}, body=${text.slice(0, 300)}`);
2946
+ }
2947
+ }
2948
+ catch (error) {
2949
+ logger.warn(`[BatchAction] Callback error: path=${pathName}, error=${error.message}`);
2950
+ }
2951
+ }
2381
2952
  sendMessage(ws, message, deviceId) {
2382
2953
  try {
2383
2954
  const msg = {