@aiscene/aiserver 1.8.3 → 1.8.5

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 (39) hide show
  1. package/dist/capture/capture-manager.d.ts +97 -0
  2. package/dist/capture/capture-manager.d.ts.map +1 -0
  3. package/dist/capture/capture-manager.js +426 -0
  4. package/dist/capture/capture-manager.js.map +1 -0
  5. package/dist/capture/knowledge-organizer.d.ts +20 -0
  6. package/dist/capture/knowledge-organizer.d.ts.map +1 -0
  7. package/dist/capture/knowledge-organizer.js +274 -0
  8. package/dist/capture/knowledge-organizer.js.map +1 -0
  9. package/dist/capture/page-extractor.d.ts +12 -0
  10. package/dist/capture/page-extractor.d.ts.map +1 -0
  11. package/dist/capture/page-extractor.js +245 -0
  12. package/dist/capture/page-extractor.js.map +1 -0
  13. package/dist/capture/types.d.ts +152 -0
  14. package/dist/capture/types.d.ts.map +1 -0
  15. package/dist/capture/types.js +7 -0
  16. package/dist/capture/types.js.map +1 -0
  17. package/dist/config/index.d.ts.map +1 -1
  18. package/dist/config/index.js +5 -0
  19. package/dist/config/index.js.map +1 -1
  20. package/dist/config/schema.d.ts +10 -0
  21. package/dist/config/schema.d.ts.map +1 -1
  22. package/dist/debug/types.d.ts +67 -1
  23. package/dist/debug/types.d.ts.map +1 -1
  24. package/dist/debug/websocket-server.d.ts +9 -0
  25. package/dist/debug/websocket-server.d.ts.map +1 -1
  26. package/dist/debug/websocket-server.js +553 -0
  27. package/dist/debug/websocket-server.js.map +1 -1
  28. package/dist/login/android-login-context.js +2 -2
  29. package/dist/login/android-login-context.js.map +1 -1
  30. package/dist/node/service.d.ts.map +1 -1
  31. package/dist/node/service.js +1 -0
  32. package/dist/node/service.js.map +1 -1
  33. package/dist/recorder/web-login-replay.d.ts.map +1 -1
  34. package/dist/recorder/web-login-replay.js +1 -0
  35. package/dist/recorder/web-login-replay.js.map +1 -1
  36. package/dist/web/server.d.ts.map +1 -1
  37. package/dist/web/server.js +131 -0
  38. package/dist/web/server.js.map +1 -1
  39. package/package.json +1 -1
@@ -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;
@@ -2074,6 +2088,508 @@ export class DebugWebSocketServer {
2074
2088
  }
2075
2089
  }
2076
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
+ try {
2376
+ // 执行登录(仅首条或 freshBrowserPerCase 时)
2377
+ const effectiveLoginContext = tc.loginContext || batchLoginContext;
2378
+ if (effectiveLoginContext && (caseIndex === 0 || request.freshBrowserPerCase || !request.loginContext)) {
2379
+ await executeWebLogin({
2380
+ source: { loginContext: effectiveLoginContext },
2381
+ page,
2382
+ logPrefix: caseLabel,
2383
+ });
2384
+ }
2385
+ // 导航到目标URL
2386
+ const targetUrl = tc.url || request.url;
2387
+ if (targetUrl && webActionRequest.actionType !== 'goto') {
2388
+ await this.safeWebGoto(page, targetUrl, `${caseLabel} Navigate`);
2389
+ }
2390
+ // 执行动作
2391
+ const actionType = tc.actionType || 'aiAction';
2392
+ if (actionType === 'aiAction' || actionType === 'aiAct') {
2393
+ const text = tc.naturalLanguage || '';
2394
+ if (!text)
2395
+ throw new Error('aiAction requires naturalLanguage');
2396
+ await agent.aiAct(text);
2397
+ }
2398
+ else if (actionType === 'goto') {
2399
+ if (!targetUrl)
2400
+ throw new Error('goto requires url');
2401
+ await this.safeWebGoto(page, targetUrl, `${caseLabel} goto`);
2402
+ }
2403
+ else if (actionType === 'runCode') {
2404
+ const code = tc.actionParams?.code || '';
2405
+ if (!code)
2406
+ throw new Error('runCode requires actionParams.code');
2407
+ const AsyncFunctionCtor = async function () { }.constructor;
2408
+ const ctx = {
2409
+ agent, page,
2410
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
2411
+ nativeSwipe: (direction, duration) => playwrightSwipe(page, { direction, duration: duration || 300 }),
2412
+ console: {
2413
+ log: (...args) => {
2414
+ sessionManager.addLog(sessionId, args.map(String).join(' '));
2415
+ this.sendMessage(ws, { type: 'log_output', sessionId, content: args.map(String).join(' ') });
2416
+ },
2417
+ },
2418
+ };
2419
+ const fn = new AsyncFunctionCtor(code);
2420
+ await fn.call(ctx);
2421
+ }
2422
+ caseSuccess = true;
2423
+ // 提取报告
2424
+ let reportFile = '';
2425
+ try {
2426
+ const reportSess = sessionManager.get(sessionId);
2427
+ if (reportSess?.webAgent && typeof reportSess.webAgent.reportFile === 'string') {
2428
+ reportFile = reportSess.webAgent.reportFile || '';
2429
+ }
2430
+ }
2431
+ catch { /* ignore */ }
2432
+ caseReportUrl = (await this.safeUploadReport(reportFile, `${request.batchId}-${tc.id}`)) ?? undefined;
2433
+ // 停止抓包
2434
+ try {
2435
+ const capturedRequests = await whistleManager.stopCapture(sessionId);
2436
+ if (capturedRequests.length > 0) {
2437
+ const uploadResult = await this.saveAndUploadCaptureData(capturedRequests, `${request.batchId}-${tc.id}`);
2438
+ if (uploadResult)
2439
+ caseCaptureDataUrl = uploadResult;
2440
+ }
2441
+ }
2442
+ catch { /* ignore */ }
2443
+ }
2444
+ finally {
2445
+ // 无论成功/失败/异常,都必须重置执行标记,否则 closeWebSession 会因 webActionRunning=true 跳过关闭,导致会话名额泄漏
2446
+ const sess = sessionManager.get(sessionId);
2447
+ if (sess)
2448
+ sess.webActionRunning = false;
2449
+ }
2450
+ return { success: caseSuccess, error: caseError, reportUrl: caseReportUrl, captureDataUrl: caseCaptureDataUrl, sessionId, browserLaunched };
2451
+ }
2452
+ /** 批次执行 - 单条 App (Android/iOS) 用例 */
2453
+ async executeBatchAppCase(ws, request, tc, caseIndex, caseLabel, batchLoginContext, batchModelConfig) {
2454
+ const { platform, batchId } = request;
2455
+ const effectiveDeviceId = tc.deviceId || request.deviceId;
2456
+ const effectivePackageName = tc.packageName || request.packageName;
2457
+ const effectiveBundleId = tc.bundleId || request.bundleId;
2458
+ const effectiveLoginContext = tc.loginContext || batchLoginContext;
2459
+ const caseSessionId = `batch-${batchId}-case-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
2460
+ const session = sessionManager.create(caseSessionId, ws, effectiveDeviceId);
2461
+ this.activeBatchSessionIds.add(caseSessionId);
2462
+ const caseAbortController = new AbortController();
2463
+ session.abortController = caseAbortController;
2464
+ // 应用模型配置
2465
+ const fullModelConfig = this.applyModelConfig(batchModelConfig);
2466
+ let caseSuccess = false;
2467
+ let caseError;
2468
+ let caseReportUrl;
2469
+ let caseCaptureDataUrl;
2470
+ try {
2471
+ sessionManager.updateStatus(caseSessionId, 'running');
2472
+ // 执行 Android 登录
2473
+ if (platform === 'android') {
2474
+ await executeAndroidLogin({
2475
+ source: { loginContext: effectiveLoginContext },
2476
+ deviceId: effectiveDeviceId,
2477
+ platform,
2478
+ logPrefix: caseLabel,
2479
+ });
2480
+ }
2481
+ // 创建 Executor
2482
+ const executor = ExecutorFactory.create('action', {
2483
+ sessionId: caseSessionId,
2484
+ onLog: (msg) => {
2485
+ sessionManager.addLog(caseSessionId, msg);
2486
+ this.sendMessage(ws, { type: 'log_output', sessionId: caseSessionId, content: msg }, effectiveDeviceId);
2487
+ },
2488
+ onDump: (dump) => {
2489
+ this.sendMessage(ws, { type: 'action_dump', sessionId: caseSessionId, dump }, effectiveDeviceId);
2490
+ },
2491
+ onExecutionLine: (line) => {
2492
+ this.sendMessage(ws, { type: 'execution_line', sessionId: caseSessionId, line }, effectiveDeviceId);
2493
+ },
2494
+ });
2495
+ session.executor = executor;
2496
+ // 启动抓包
2497
+ try {
2498
+ await whistleManager.startCapture(caseSessionId, undefined, 3000, request.proxyAccount);
2499
+ logger.info(`[BatchAction] Whistle capture started: sessionId=${caseSessionId}`);
2500
+ }
2501
+ catch (captureErr) {
2502
+ logger.warn(`[BatchAction] Failed to start capture: ${captureErr.message}`);
2503
+ }
2504
+ // 构造 ExecuteConfig
2505
+ const actionType = tc.actionType || 'aiAct';
2506
+ const config = {
2507
+ type: platform,
2508
+ testName: tc.title || tc.id || `batch-${batchId}-case-${caseIndex + 1}`,
2509
+ actionType,
2510
+ naturalLanguage: tc.naturalLanguage,
2511
+ locatePrompt: tc.actionParams?.locatePrompt,
2512
+ actionParams: tc.actionParams,
2513
+ udid: effectiveDeviceId,
2514
+ packageName: effectivePackageName,
2515
+ bundleId: effectiveBundleId,
2516
+ modelConfig: fullModelConfig,
2517
+ executionId: caseSessionId,
2518
+ skipAppRestart: request.skipAppRestart,
2519
+ proxyPort: request.proxyPort,
2520
+ proxyAccount: request.proxyAccount,
2521
+ };
2522
+ // 使用 AbortSignal 监听停止
2523
+ const executePromise = executor.execute(config);
2524
+ const abortPromise = new Promise((resolve) => {
2525
+ caseAbortController.signal.addEventListener('abort', () => {
2526
+ resolve({ success: false, errorMessage: 'Action stopped by user' });
2527
+ }, { once: true });
2528
+ });
2529
+ const result = await Promise.race([executePromise, abortPromise]);
2530
+ // 清理 executor
2531
+ if (executor && typeof executor.destroy === 'function') {
2532
+ try {
2533
+ await executor.destroy();
2534
+ }
2535
+ catch { /* ignore */ }
2536
+ }
2537
+ session.executor = undefined;
2538
+ caseSuccess = result.success;
2539
+ if (!result.success && result.errorMessage)
2540
+ caseError = result.errorMessage;
2541
+ // 上传报告
2542
+ const reportFile = result.reportFile || '';
2543
+ caseReportUrl = (await this.safeUploadReport(reportFile, `${batchId}-${tc.id}`)) ?? undefined;
2544
+ // 停止抓包
2545
+ try {
2546
+ const capturedRequests = await whistleManager.stopCapture(caseSessionId);
2547
+ if (capturedRequests.length > 0) {
2548
+ const uploadResult = await this.saveAndUploadCaptureData(capturedRequests, `${batchId}-${tc.id}`);
2549
+ if (uploadResult)
2550
+ caseCaptureDataUrl = uploadResult;
2551
+ }
2552
+ }
2553
+ catch { /* ignore */ }
2554
+ }
2555
+ catch (err) {
2556
+ caseError = err.message;
2557
+ caseSuccess = false;
2558
+ // 清理 executor
2559
+ if (session.executor && typeof session.executor.destroy === 'function') {
2560
+ try {
2561
+ await session.executor.destroy();
2562
+ }
2563
+ catch { /* ignore */ }
2564
+ }
2565
+ session.executor = undefined;
2566
+ }
2567
+ sessionManager.updateStatus(caseSessionId, caseSuccess ? 'completed' : 'failed');
2568
+ this.activeBatchSessionIds.delete(caseSessionId);
2569
+ try {
2570
+ await this.cleanupSession(caseSessionId);
2571
+ }
2572
+ catch (cleanupError) {
2573
+ logger.warn(`[BatchAction] Session cleanup failed: sessionId=${caseSessionId}, error=${cleanupError.message}`);
2574
+ }
2575
+ return { success: caseSuccess, error: caseError, reportUrl: caseReportUrl, captureDataUrl: caseCaptureDataUrl, sessionId: caseSessionId };
2576
+ }
2577
+ // ==================== Stop Batch ====================
2578
+ async handleStopBatch(ws, request) {
2579
+ const { batchId } = request;
2580
+ const abortController = this.batchAbortControllers.get(batchId);
2581
+ if (abortController) {
2582
+ abortController.abort();
2583
+ this.batchAbortControllers.delete(batchId);
2584
+ this.sendMessage(ws, { type: 'batch_stopped', batchId });
2585
+ }
2586
+ else {
2587
+ this.sendMessage(ws, {
2588
+ type: 'error',
2589
+ message: `No active batch found with id: ${batchId}`,
2590
+ });
2591
+ }
2592
+ }
2077
2593
  // ==================== Start Screencast (for existing web session) ====================
2078
2594
  async handleStartScreencast(ws, request) {
2079
2595
  const session = sessionManager.get(request.sessionId);
@@ -2380,6 +2896,18 @@ export class DebugWebSocketServer {
2380
2896
  logger.info(`[ModelConfig] Applied ${key} = ${value}`);
2381
2897
  }
2382
2898
  }
2899
+ // 数值型环境变量:前端可能传 string 或 number
2900
+ const numericEnvKeys = [
2901
+ 'MIDSCENE_REPLANNING_CYCLE_LIMIT',
2902
+ ];
2903
+ for (const key of numericEnvKeys) {
2904
+ const raw = modelConfig[key];
2905
+ const value = raw != null ? String(raw) : undefined;
2906
+ if (value) {
2907
+ process.env[key] = value;
2908
+ logger.info(`[ModelConfig] Applied ${key} = ${value}`);
2909
+ }
2910
+ }
2383
2911
  }
2384
2912
  // 2. Always force-apply Planning model config from server config
2385
2913
  const planningDefaults = {
@@ -2412,6 +2940,31 @@ export class DebugWebSocketServer {
2412
2940
  logger.info(`[ModelConfig] Model config applied successfully`);
2413
2941
  return result;
2414
2942
  }
2943
+ async postBatchCallback(request, pathName, payload) {
2944
+ const baseUrl = request.callback?.baseUrl?.trim();
2945
+ if (!baseUrl)
2946
+ return;
2947
+ try {
2948
+ const relativePath = pathName.replace(/^\/+/, '');
2949
+ const url = new URL(relativePath, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
2950
+ const headers = { 'Content-Type': 'application/json' };
2951
+ const token = request.callback?.token?.trim();
2952
+ if (token)
2953
+ headers.Authorization = token.startsWith('Bearer ') ? token : `Bearer ${token}`;
2954
+ const response = await fetch(url.toString(), {
2955
+ method: 'POST',
2956
+ headers,
2957
+ body: JSON.stringify(payload),
2958
+ });
2959
+ if (!response.ok) {
2960
+ const text = await response.text().catch(() => '');
2961
+ logger.warn(`[BatchAction] Callback failed: ${response.status} ${response.statusText}, path=${pathName}, body=${text.slice(0, 300)}`);
2962
+ }
2963
+ }
2964
+ catch (error) {
2965
+ logger.warn(`[BatchAction] Callback error: path=${pathName}, error=${error.message}`);
2966
+ }
2967
+ }
2415
2968
  sendMessage(ws, message, deviceId) {
2416
2969
  try {
2417
2970
  const msg = {