@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.
- package/dist/api/callback.d.ts.map +1 -1
- package/dist/api/callback.js +5 -0
- package/dist/api/callback.js.map +1 -1
- package/dist/api/device-api.d.ts.map +1 -1
- package/dist/api/device-api.js +9 -2
- package/dist/api/device-api.js.map +1 -1
- package/dist/api/task-api.d.ts.map +1 -1
- package/dist/api/task-api.js +6 -1
- package/dist/api/task-api.js.map +1 -1
- package/dist/auth/browser-auth.d.ts +4 -0
- package/dist/auth/browser-auth.d.ts.map +1 -0
- package/dist/auth/browser-auth.js +59 -0
- package/dist/auth/browser-auth.js.map +1 -0
- package/dist/auth/node-auth.d.ts +27 -0
- package/dist/auth/node-auth.d.ts.map +1 -0
- package/dist/auth/node-auth.js +132 -0
- package/dist/auth/node-auth.js.map +1 -0
- package/dist/config/cli.d.ts +12 -0
- package/dist/config/cli.d.ts.map +1 -1
- package/dist/config/cli.js +59 -2
- package/dist/config/cli.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +32 -10
- package/dist/config/index.js.map +1 -1
- package/dist/config/schema.d.ts +3 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/core/types.d.ts +15 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/debug/types.d.ts +69 -1
- package/dist/debug/types.d.ts.map +1 -1
- package/dist/debug/web-screencast.d.ts.map +1 -1
- package/dist/debug/web-screencast.js +0 -6
- package/dist/debug/web-screencast.js.map +1 -1
- package/dist/debug/websocket-server.d.ts +15 -3
- package/dist/debug/websocket-server.d.ts.map +1 -1
- package/dist/debug/websocket-server.js +674 -103
- package/dist/debug/websocket-server.js.map +1 -1
- package/dist/device/heartbeat.d.ts.map +1 -1
- package/dist/device/heartbeat.js +9 -2
- package/dist/device/heartbeat.js.map +1 -1
- package/dist/executor/action-executor.d.ts.map +1 -1
- package/dist/executor/action-executor.js +6 -0
- package/dist/executor/action-executor.js.map +1 -1
- package/dist/executor/android-executor.d.ts +3 -0
- package/dist/executor/android-executor.d.ts.map +1 -1
- package/dist/executor/android-executor.js +51 -0
- package/dist/executor/android-executor.js.map +1 -1
- package/dist/executor/cli-executor.d.ts +3 -1
- package/dist/executor/cli-executor.d.ts.map +1 -1
- package/dist/executor/cli-executor.js +59 -77
- package/dist/executor/cli-executor.js.map +1 -1
- package/dist/executor/ios-executor.d.ts.map +1 -1
- package/dist/executor/ios-executor.js +4 -0
- package/dist/executor/ios-executor.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +79 -27
- package/dist/index.js.map +1 -1
- package/dist/login/android-login-context.d.ts +1 -0
- package/dist/login/android-login-context.d.ts.map +1 -1
- package/dist/login/android-login-context.js +78 -13
- package/dist/login/android-login-context.js.map +1 -1
- package/dist/node/service.d.ts.map +1 -1
- package/dist/node/service.js +13 -3
- package/dist/node/service.js.map +1 -1
- package/dist/recorder/web-login-replay.d.ts.map +1 -1
- package/dist/recorder/web-login-replay.js +1 -0
- package/dist/recorder/web-login-replay.js.map +1 -1
- package/dist/task/execution-profile.d.ts +32 -0
- package/dist/task/execution-profile.d.ts.map +1 -0
- package/dist/task/execution-profile.js +227 -0
- package/dist/task/execution-profile.js.map +1 -0
- package/dist/task/scheduler.d.ts +1 -0
- package/dist/task/scheduler.d.ts.map +1 -1
- package/dist/task/scheduler.js +28 -27
- package/dist/task/scheduler.js.map +1 -1
- package/dist/task/types.js +1 -0
- package/dist/task/types.js.map +1 -1
- package/dist/web/server.d.ts +6 -1
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +141 -9
- package/dist/web/server.js.map +1 -1
- 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
|
|
495
|
+
* Web 调试主进程内嵌执行
|
|
482
496
|
* - 首次请求:懒启动浏览器 + PlaywrightAgent + 自动投屏
|
|
483
497
|
* - 复用请求:跳过初始化,直接执行 naturalLanguage
|
|
484
|
-
* -
|
|
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
|
-
|
|
601
|
-
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
1518
|
-
if (!
|
|
1519
|
-
|
|
1520
|
-
|
|
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
|
-
|
|
1536
|
+
}
|
|
1537
|
+
getCurrentWebUrl(page) {
|
|
1523
1538
|
try {
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
await page.
|
|
1546
|
-
|
|
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
|
-
|
|
1568
|
-
|
|
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
|
-
//
|
|
1674
|
+
// 桌面模式:每次执行独立启动 Chromium,结束后真正关闭,避免残留 Chrome 进程。
|
|
1646
1675
|
const contextOptions = {
|
|
1647
1676
|
viewport: request.viewport || { width: 1280, height: 800 },
|
|
1648
1677
|
};
|
|
1649
|
-
|
|
1650
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 = {
|