ornold-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1960 @@
1
+ import * as playwright from 'patchright';
2
+ import type { Browser, BrowserContext, Page } from 'patchright';
3
+ import type {
4
+ BrowserEndpoint,
5
+ ConnectedBrowser,
6
+ ParallelResult,
7
+ ParallelResults,
8
+ BrowserStatus,
9
+ SnapshotResult,
10
+ NavigationTarget,
11
+ TextMapping,
12
+ VariableMapping,
13
+ } from './types.js';
14
+ import { ConcurrencyLimiter, detectOptimalConcurrency } from './concurrency.js';
15
+ import {
16
+ generateProfile,
17
+ preActionDelay,
18
+ humanMouseMove,
19
+ humanClick,
20
+ getClickTarget,
21
+ humanTypeText,
22
+ humanDelay,
23
+ ensureCursor,
24
+ type HumanProfile,
25
+ } from './human-like.js';
26
+
27
+ /**
28
+ * MultiBrowserExecutor - Core class for managing multiple browser connections
29
+ * and executing parallel operations across all of them.
30
+ * Uses ref-based element targeting like the original Playwright MCP.
31
+ *
32
+ * OPTIMIZATION: Uses concurrency limiter with auto-detection based on system resources.
33
+ */
34
+ export class MultiBrowserExecutor {
35
+ private browsers: Map<string, ConnectedBrowser> = new Map();
36
+ private defaultTimeout: number;
37
+ private limiter: ConcurrencyLimiter;
38
+ private heavyLimiter: ConcurrencyLimiter;
39
+ private lightLimiter: ConcurrencyLimiter;
40
+
41
+ // Cache for browser sync - prevents excessive API calls
42
+ private lastSyncTime: number = 0;
43
+ private syncInProgress: Promise<void> | null = null;
44
+ private static readonly SYNC_TTL_MS = 5000; // 5 seconds cache TTL
45
+
46
+ // Track last mouse position per browser for human-like movement
47
+ private lastMousePosition: Map<string, { x: number; y: number }> = new Map();
48
+ // Per-browser human profiles for varied timing
49
+ private profiles: Map<string, HumanProfile> = new Map();
50
+
51
+ // Console messages collected per browser (since connect time)
52
+ private consoleMessages: Map<string, Array<{ type: string; text: string; timestamp: number }>> = new Map();
53
+ // Network requests collected per browser (since connect time)
54
+ private networkRequests: Map<string, Array<{ url: string; method: string; status?: number; resourceType: string; timestamp: number }>> = new Map();
55
+
56
+ constructor(options: { timeout?: number; maxConcurrency?: number } = {}) {
57
+ this.defaultTimeout = options.timeout ?? 5000; // 5 seconds like original MCP
58
+
59
+ // Auto-detect optimal concurrency based on system resources
60
+ const detected = detectOptimalConcurrency();
61
+
62
+ // Allow override via options, otherwise use detected values
63
+ const defaultConcurrency = options.maxConcurrency ?? detected.default;
64
+ const heavyConcurrency = options.maxConcurrency
65
+ ? Math.max(2, Math.floor(options.maxConcurrency * 0.75))
66
+ : detected.heavy;
67
+ const lightConcurrency = options.maxConcurrency
68
+ ? Math.min(options.maxConcurrency * 1.5, 10)
69
+ : detected.light;
70
+
71
+ this.limiter = new ConcurrencyLimiter(defaultConcurrency);
72
+ this.heavyLimiter = new ConcurrencyLimiter(heavyConcurrency);
73
+ this.lightLimiter = new ConcurrencyLimiter(lightConcurrency);
74
+
75
+ console.error(`[Executor] Concurrency limiters initialized: default=${defaultConcurrency}, heavy=${heavyConcurrency}, light=${lightConcurrency}`);
76
+ }
77
+
78
+ // ==================== CONNECTION MANAGEMENT ====================
79
+
80
+ /**
81
+ * Connect to multiple browsers with controlled concurrency.
82
+ * OPTIMIZATION: Limits simultaneous connections to prevent overwhelming CDP endpoints.
83
+ */
84
+ async connectAll(endpoints: BrowserEndpoint[]): Promise<ParallelResults<BrowserStatus>> {
85
+ const start = Date.now();
86
+ console.error(`[Executor] connectAll: starting connection to ${endpoints.length} browsers with concurrency limit`);
87
+
88
+ // Use a dedicated limiter for connections - don't overwhelm CDP
89
+ // ✅ ИСПРАВЛЕНИЕ: Убран лимит 3 для RunPod сервера
90
+ // Теперь используем heavy limiter без дополнительных ограничений
91
+ const connectionLimiter = new ConcurrencyLimiter(
92
+ this.heavyLimiter.limit
93
+ );
94
+
95
+ const tasks = endpoints.map(async (endpoint): Promise<ParallelResult<BrowserStatus>> => {
96
+ return connectionLimiter.run(async () => {
97
+ const taskStart = Date.now();
98
+ try {
99
+ console.error(`[Executor] Connecting to ${endpoint.id} at ${endpoint.cdpEndpoint}...`);
100
+ const result = await this.connect(endpoint);
101
+ console.error(`[Executor] ✅ Connected to ${endpoint.id}: ${result.url}`);
102
+ return {
103
+ browserId: endpoint.id,
104
+ success: true,
105
+ result,
106
+ duration: Date.now() - taskStart,
107
+ };
108
+ } catch (error) {
109
+ console.error(`[Executor] ❌ Failed to connect to ${endpoint.id}: ${error}`);
110
+ return {
111
+ browserId: endpoint.id,
112
+ success: false,
113
+ error: String(error),
114
+ duration: Date.now() - taskStart,
115
+ };
116
+ }
117
+ });
118
+ });
119
+
120
+ const results = await Promise.all(tasks);
121
+
122
+ console.error(`[Executor] connectAll complete: ${results.filter(r => r.success).length}/${results.length} connected in ${Date.now() - start}ms`);
123
+ return {
124
+ results,
125
+ totalDuration: Date.now() - start,
126
+ successCount: results.filter(r => r.success).length,
127
+ failureCount: results.filter(r => !r.success).length,
128
+ };
129
+ }
130
+
131
+ async connect(endpoint: BrowserEndpoint, retries = 3): Promise<BrowserStatus> {
132
+ if (this.browsers.has(endpoint.id)) {
133
+ throw new Error(`Browser ${endpoint.id} is already connected`);
134
+ }
135
+
136
+ let lastError: Error | null = null;
137
+
138
+ for (let attempt = 1; attempt <= retries; attempt++) {
139
+ try {
140
+ console.error(`[Executor] Attempt ${attempt}/${retries} to connect to ${endpoint.id} at ${endpoint.cdpEndpoint}`);
141
+
142
+ const browser = await playwright.chromium.connectOverCDP(endpoint.cdpEndpoint, {
143
+ headers: endpoint.cdpHeaders,
144
+ });
145
+
146
+ // Success - continue with the rest of the connect logic
147
+ return await this._finishConnect(endpoint, browser);
148
+ } catch (error) {
149
+ lastError = error instanceof Error ? error : new Error(String(error));
150
+ console.error(`[Executor] Attempt ${attempt}/${retries} failed for ${endpoint.id}: ${lastError.message}`);
151
+
152
+ if (attempt < retries) {
153
+ // Wait before retry (fast exponential backoff: 300ms, 600ms, 1200ms)
154
+ const delay = Math.pow(2, attempt - 1) * 300;
155
+ console.error(`[Executor] Retrying in ${delay}ms...`);
156
+ await new Promise(r => setTimeout(r, delay));
157
+ }
158
+ }
159
+ }
160
+
161
+ throw lastError || new Error(`Failed to connect to ${endpoint.id} after ${retries} attempts`);
162
+ }
163
+
164
+ private async _finishConnect(endpoint: BrowserEndpoint, browser: Browser): Promise<BrowserStatus> {
165
+ let context: BrowserContext;
166
+ let page: Page;
167
+
168
+ const contexts = browser.contexts();
169
+ console.error(`[Executor] Browser ${endpoint.id} has ${contexts.length} contexts`);
170
+
171
+ if (contexts.length > 0) {
172
+ context = contexts[0];
173
+ const pages = context.pages();
174
+ console.error(`[Executor] Context has ${pages.length} pages`);
175
+
176
+ if (pages.length > 0) {
177
+ // Используем существующую страницу (первую не-blank, или первую)
178
+ page = pages.find(p => !p.url().startsWith('about:')) || pages[0];
179
+ console.error(`[Executor] Using existing page: ${page.url()}`);
180
+ } else {
181
+ // Нет страниц - для Linken Sphere через прокси это нормально,
182
+ // ждём появления страницы вместо создания новой
183
+ console.error(`[Executor] No pages in context, waiting for existing page...`);
184
+
185
+ // Ждём до 2 секунд пока появится страница (fast polling)
186
+ const waitStart = Date.now();
187
+ while (Date.now() - waitStart < 2000) {
188
+ const currentPages = context.pages();
189
+ if (currentPages.length > 0) {
190
+ page = currentPages.find(p => !p.url().startsWith('about:')) || currentPages[0];
191
+ console.error(`[Executor] Found page after waiting: ${page.url()}`);
192
+ break;
193
+ }
194
+ await new Promise(r => setTimeout(r, 100));
195
+ }
196
+
197
+ // Если всё ещё нет страниц - создаём только в крайнем случае
198
+ if (!page!) {
199
+ console.error(`[Executor] No pages appeared after 2s, creating new page (fallback)`);
200
+ page = await context.newPage();
201
+ }
202
+ }
203
+ } else {
204
+ // Нет контекстов - тоже ждём, для CDP over proxy контексты могут появиться позже
205
+ console.error(`[Executor] No contexts, waiting for context to appear...`);
206
+
207
+ const waitStart = Date.now();
208
+ while (Date.now() - waitStart < 2000) {
209
+ const currentContexts = browser.contexts();
210
+ if (currentContexts.length > 0) {
211
+ context = currentContexts[0];
212
+ const pages = context.pages();
213
+ if (pages.length > 0) {
214
+ page = pages.find(p => !p.url().startsWith('about:')) || pages[0];
215
+ console.error(`[Executor] Found context and page after waiting: ${page!.url()}`);
216
+ break;
217
+ }
218
+ }
219
+ await new Promise(r => setTimeout(r, 100));
220
+ }
221
+
222
+ // Fallback - создаём только если ничего не появилось
223
+ if (!context!) {
224
+ console.error(`[Executor] Creating new context and page for ${endpoint.id} (fallback)`);
225
+ context = await browser.newContext();
226
+ page = await context.newPage();
227
+ } else if (!page!) {
228
+ console.error(`[Executor] Context found but no pages, creating page (fallback)`);
229
+ page = await context.newPage();
230
+ }
231
+ }
232
+
233
+ // Clean up extra about:blank pages created by connectOverCDP
234
+ // Keep only the page we selected — close the rest if they're blank
235
+ try {
236
+ const allPages = context.pages();
237
+ for (const p of allPages) {
238
+ if (p !== page && p.url() === 'about:blank') {
239
+ await p.close().catch(() => {});
240
+ console.error(`[Executor] Closed extra about:blank page for ${endpoint.id}`);
241
+ }
242
+ }
243
+ } catch {}
244
+
245
+ // Set timeouts like original MCP
246
+ page.setDefaultTimeout(this.defaultTimeout);
247
+ page.setDefaultNavigationTimeout(60000);
248
+
249
+ const connected: ConnectedBrowser = {
250
+ id: endpoint.id,
251
+ browser,
252
+ context,
253
+ page,
254
+ endpoint,
255
+ connectedAt: new Date(),
256
+ };
257
+
258
+ this.browsers.set(endpoint.id, connected);
259
+ console.error(`[Executor] Successfully stored ${endpoint.id} in browsers map. Total browsers: ${this.browsers.size}`);
260
+
261
+ // Set up console message tracking
262
+ const consoleLog: Array<{ type: string; text: string; timestamp: number }> = [];
263
+ this.consoleMessages.set(endpoint.id, consoleLog);
264
+ page.on('console', (msg) => {
265
+ consoleLog.push({ type: msg.type(), text: msg.text(), timestamp: Date.now() });
266
+ if (consoleLog.length > 1000) consoleLog.shift();
267
+ });
268
+
269
+ // Set up network request tracking
270
+ const networkLog: Array<{ url: string; method: string; status?: number; resourceType: string; timestamp: number }> = [];
271
+ this.networkRequests.set(endpoint.id, networkLog);
272
+ page.on('response', (response) => {
273
+ const req = response.request();
274
+ networkLog.push({
275
+ url: req.url(),
276
+ method: req.method(),
277
+ status: response.status(),
278
+ resourceType: req.resourceType(),
279
+ timestamp: Date.now(),
280
+ });
281
+ if (networkLog.length > 1000) networkLog.shift();
282
+ });
283
+
284
+ browser.on('disconnected', () => {
285
+ console.error(`[Executor] Browser ${endpoint.id} disconnected`);
286
+ this.browsers.delete(endpoint.id);
287
+ this.consoleMessages.delete(endpoint.id);
288
+ this.networkRequests.delete(endpoint.id);
289
+
290
+ // Auto-reconnect after 500ms (fast reconnect)
291
+ setTimeout(async () => {
292
+ if (!this.browsers.has(endpoint.id)) {
293
+ console.error(`[Executor] Attempting auto-reconnect for ${endpoint.id}...`);
294
+ try {
295
+ await this.connect(endpoint, 2); // 2 retries for reconnect
296
+ console.error(`[Executor] ✅ Auto-reconnected ${endpoint.id}`);
297
+ } catch (e) {
298
+ console.error(`[Executor] ❌ Auto-reconnect failed for ${endpoint.id}: ${e instanceof Error ? e.message : String(e)}`);
299
+ }
300
+ }
301
+ }, 500);
302
+ });
303
+
304
+ const url = page.url();
305
+ const title = await page.title().catch(() => '');
306
+ console.error(`[Executor] Browser ${endpoint.id} connected. URL: ${url}, Title: ${title}`);
307
+
308
+ return {
309
+ id: endpoint.id,
310
+ connected: true,
311
+ url,
312
+ title,
313
+ endpoint: endpoint.cdpEndpoint,
314
+ tags: endpoint.tags,
315
+ connectedAt: connected.connectedAt,
316
+ };
317
+ }
318
+
319
+ async disconnect(browserId: string): Promise<void> {
320
+ const browser = this.browsers.get(browserId);
321
+ if (!browser) throw new Error(`Browser ${browserId} not found`);
322
+ await browser.browser.close().catch(() => {});
323
+ this.browsers.delete(browserId);
324
+ this.consoleMessages.delete(browserId);
325
+ this.networkRequests.delete(browserId);
326
+ }
327
+
328
+ async disconnectAll(): Promise<void> {
329
+ const ids = Array.from(this.browsers.keys());
330
+ await Promise.all(
331
+ ids.map(id => this.disconnect(id).catch(() => {}))
332
+ );
333
+ // Force clear the map in case any disconnect failed
334
+ this.browsers.clear();
335
+ }
336
+
337
+ async listBrowsers(): Promise<BrowserStatus[]> {
338
+ const statuses: BrowserStatus[] = [];
339
+ for (const [id, browser] of this.browsers) {
340
+ try {
341
+ statuses.push({
342
+ id,
343
+ connected: browser.browser.isConnected(),
344
+ url: browser.page.url(),
345
+ title: await browser.page.title().catch(() => ''),
346
+ endpoint: browser.endpoint.cdpEndpoint,
347
+ tags: browser.endpoint.tags,
348
+ connectedAt: browser.connectedAt,
349
+ });
350
+ } catch {
351
+ statuses.push({
352
+ id,
353
+ connected: false,
354
+ endpoint: browser.endpoint.cdpEndpoint,
355
+ tags: browser.endpoint.tags,
356
+ });
357
+ }
358
+ }
359
+ return statuses;
360
+ }
361
+
362
+ /**
363
+ * Detailed status check for all browsers
364
+ */
365
+ async getDetailedStatus(options: { checkContent?: boolean } = {}): Promise<{
366
+ browsers: Array<{
367
+ id: string;
368
+ status: 'ok' | 'disconnected' | 'error' | 'behind';
369
+ url: string;
370
+ title: string;
371
+ error?: string;
372
+ responsive: boolean;
373
+ }>;
374
+ summary: {
375
+ total: number;
376
+ ok: number;
377
+ problems: number;
378
+ };
379
+ majorityUrl?: string;
380
+ }> {
381
+ const results: Array<{
382
+ id: string;
383
+ status: 'ok' | 'disconnected' | 'error' | 'behind';
384
+ url: string;
385
+ title: string;
386
+ error?: string;
387
+ responsive: boolean;
388
+ }> = [];
389
+
390
+ const urlCounts = new Map<string, number>();
391
+
392
+ for (const [id, browser] of this.browsers) {
393
+ try {
394
+ // Check if browser is connected
395
+ if (!browser.browser.isConnected()) {
396
+ results.push({
397
+ id,
398
+ status: 'disconnected',
399
+ url: '',
400
+ title: '',
401
+ error: 'Browser disconnected',
402
+ responsive: false,
403
+ });
404
+ continue;
405
+ }
406
+
407
+ // Try to get page info
408
+ const url = browser.page.url();
409
+ const title = await browser.page.title().catch(() => '');
410
+
411
+ // Check responsiveness with a simple evaluate (fast 1s timeout)
412
+ let responsive = true;
413
+ try {
414
+ let timeoutId: ReturnType<typeof setTimeout>;
415
+ const timeoutPromise = new Promise<never>((_, reject) => {
416
+ timeoutId = setTimeout(() => reject(new Error('timeout')), 1000);
417
+ });
418
+
419
+ try {
420
+ await Promise.race([
421
+ browser.page.evaluate(() => true),
422
+ timeoutPromise,
423
+ ]);
424
+ clearTimeout(timeoutId!);
425
+ } catch {
426
+ clearTimeout(timeoutId!);
427
+ responsive = false;
428
+ }
429
+ } catch {
430
+ responsive = false;
431
+ }
432
+
433
+ // Count URLs to find majority
434
+ const baseUrl = url.split('?')[0]; // Ignore query params
435
+ urlCounts.set(baseUrl, (urlCounts.get(baseUrl) || 0) + 1);
436
+
437
+ results.push({
438
+ id,
439
+ status: responsive ? 'ok' : 'error',
440
+ url,
441
+ title,
442
+ error: responsive ? undefined : 'Page not responding',
443
+ responsive,
444
+ });
445
+ } catch (error) {
446
+ results.push({
447
+ id,
448
+ status: 'error',
449
+ url: '',
450
+ title: '',
451
+ error: String(error),
452
+ responsive: false,
453
+ });
454
+ }
455
+ }
456
+
457
+ // Find majority URL (where most browsers are)
458
+ let majorityUrl: string | undefined;
459
+ let maxCount = 0;
460
+ for (const [url, count] of urlCounts) {
461
+ if (count > maxCount) {
462
+ maxCount = count;
463
+ majorityUrl = url;
464
+ }
465
+ }
466
+
467
+ // Mark browsers that are behind (different URL from majority)
468
+ if (majorityUrl && maxCount > 1) {
469
+ for (const result of results) {
470
+ if (result.status === 'ok' && result.url.split('?')[0] !== majorityUrl) {
471
+ result.status = 'behind';
472
+ }
473
+ }
474
+ }
475
+
476
+ const okCount = results.filter(r => r.status === 'ok').length;
477
+
478
+ return {
479
+ browsers: results,
480
+ summary: {
481
+ total: results.length,
482
+ ok: okCount,
483
+ problems: results.length - okCount,
484
+ },
485
+ majorityUrl,
486
+ };
487
+ }
488
+
489
+ /**
490
+ * Sync browsers from API (on-demand) with retry logic.
491
+ * Called automatically when getBrowserIds finds no connected browsers.
492
+ */
493
+ async syncFromAPI(retries = 2): Promise<void> {
494
+ const apiServerUrl = process.env.API_SERVER_URL;
495
+ const userId = process.env.USER_ID;
496
+
497
+ if (!apiServerUrl || !userId) {
498
+ console.error(`[Executor] syncFromAPI: missing API_SERVER_URL or USER_ID`);
499
+ return;
500
+ }
501
+
502
+ let lastError: Error | null = null;
503
+
504
+ for (let attempt = 1; attempt <= retries; attempt++) {
505
+ try {
506
+ const url = `${apiServerUrl}/api/browsers?userId=${userId}`;
507
+ console.error(`[Executor] syncFromAPI: fetching ${url} (attempt ${attempt}/${retries})`);
508
+
509
+ // Add timeout for fetch (fast 5s timeout)
510
+ const controller = new AbortController();
511
+ const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
512
+
513
+ try {
514
+ const response = await fetch(url, { signal: controller.signal });
515
+ clearTimeout(timeoutId);
516
+
517
+ if (!response.ok) {
518
+ throw new Error(`API returned ${response.status}`);
519
+ }
520
+
521
+ const data = await response.json() as { browsers: Array<{ id: string; cdpEndpoint: string; tags?: string[] }> };
522
+ const apiBrowsers = data.browsers || [];
523
+
524
+ console.error(`[Executor] syncFromAPI: API returned ${apiBrowsers.length} browsers`);
525
+
526
+ // Get currently connected IDs
527
+ const connectedIds = new Set(this.browsers.keys());
528
+
529
+ // Connect new browsers
530
+ for (const browser of apiBrowsers) {
531
+ if (!connectedIds.has(browser.id)) {
532
+ console.error(`[Executor] syncFromAPI: connecting ${browser.id}`);
533
+ try {
534
+ await this.connect({
535
+ id: browser.id,
536
+ cdpEndpoint: browser.cdpEndpoint,
537
+ tags: browser.tags || [],
538
+ });
539
+ console.error(`[Executor] syncFromAPI: ✓ connected ${browser.id}`);
540
+ } catch (e) {
541
+ console.error(`[Executor] syncFromAPI: ✗ failed ${browser.id}: ${e instanceof Error ? e.message : String(e)}`);
542
+ }
543
+ }
544
+ }
545
+
546
+ // Disconnect stale browsers (in local but not in API)
547
+ const apiIds = new Set(apiBrowsers.map(b => b.id));
548
+ for (const id of connectedIds) {
549
+ if (!apiIds.has(id)) {
550
+ console.error(`[Executor] syncFromAPI: disconnecting stale ${id}`);
551
+ try {
552
+ await this.disconnect(id);
553
+ } catch {
554
+ // Ignore disconnect errors
555
+ }
556
+ }
557
+ }
558
+
559
+ console.error(`[Executor] syncFromAPI: done, now have ${this.browsers.size} browsers`);
560
+ this.lastSyncTime = Date.now(); // Update cache timestamp
561
+ return; // Success, exit
562
+ } catch (e) {
563
+ clearTimeout(timeoutId);
564
+ throw e;
565
+ }
566
+ } catch (e) {
567
+ lastError = e instanceof Error ? e : new Error(String(e));
568
+ console.error(`[Executor] syncFromAPI: attempt ${attempt}/${retries} failed: ${lastError.message}`);
569
+
570
+ if (attempt < retries) {
571
+ const delay = Math.pow(2, attempt - 1) * 300; // Fast exponential backoff
572
+ console.error(`[Executor] syncFromAPI: retrying in ${delay}ms...`);
573
+ await new Promise(r => setTimeout(r, delay));
574
+ }
575
+ }
576
+ }
577
+
578
+ console.error(`[Executor] syncFromAPI: all ${retries} attempts failed`);
579
+ }
580
+
581
+ getBrowserIds(filter?: { browserIds?: string[]; tags?: string[] }): string[] {
582
+ let ids = Array.from(this.browsers.keys());
583
+ if (filter?.browserIds?.length) {
584
+ ids = ids.filter(id => filter.browserIds!.includes(id));
585
+ }
586
+ if (filter?.tags?.length) {
587
+ ids = ids.filter(id => {
588
+ const browser = this.browsers.get(id);
589
+ return browser?.endpoint.tags?.some(t => filter.tags!.includes(t));
590
+ });
591
+ }
592
+ return ids;
593
+ }
594
+
595
+ /**
596
+ * Ensure browsers are loaded before operations.
597
+ * Uses caching to prevent excessive API calls.
598
+ * Only syncs if: no browsers connected AND cache expired.
599
+ */
600
+ async ensureBrowsersLoaded(): Promise<void> {
601
+ // If we have browsers, skip sync entirely
602
+ if (this.browsers.size > 0) {
603
+ return;
604
+ }
605
+
606
+ // Check cache TTL
607
+ const now = Date.now();
608
+ if (now - this.lastSyncTime < MultiBrowserExecutor.SYNC_TTL_MS) {
609
+ console.error(`[Executor] ensureBrowsersLoaded: skipping sync, cache valid (${now - this.lastSyncTime}ms old)`);
610
+ return;
611
+ }
612
+
613
+ // Deduplicate concurrent sync requests
614
+ if (this.syncInProgress) {
615
+ console.error(`[Executor] ensureBrowsersLoaded: sync already in progress, waiting...`);
616
+ await this.syncInProgress;
617
+ return;
618
+ }
619
+
620
+ console.error(`[Executor] ensureBrowsersLoaded: no browsers, syncing from API...`);
621
+ this.syncInProgress = this.syncFromAPI().finally(() => {
622
+ this.syncInProgress = null;
623
+ });
624
+ await this.syncInProgress;
625
+ }
626
+
627
+ getBrowserEndpoint(browserId: string): BrowserEndpoint | undefined {
628
+ const browser = this.browsers.get(browserId);
629
+ return browser?.endpoint;
630
+ }
631
+
632
+ getPage(browserId: string): Page {
633
+ const browser = this.browsers.get(browserId);
634
+ if (!browser) throw new Error(`Browser ${browserId} not found`);
635
+ return browser.page;
636
+ }
637
+
638
+ // ==================== PARALLEL EXECUTION ====================
639
+
640
+ /**
641
+ * Get the appropriate limiter based on operation type
642
+ */
643
+ private getLimiter(type?: 'heavy' | 'light' | number): ConcurrencyLimiter {
644
+ if (typeof type === 'number') {
645
+ return new ConcurrencyLimiter(type);
646
+ }
647
+ switch (type) {
648
+ case 'heavy':
649
+ return this.heavyLimiter;
650
+ case 'light':
651
+ return this.lightLimiter;
652
+ default:
653
+ return this.limiter;
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Execute action on multiple browsers with controlled concurrency.
659
+ * This prevents system overload when operating on 9+ browsers.
660
+ * Concurrency is auto-detected based on system resources (RAM, CPU cores).
661
+ *
662
+ * @param browserIds - specific browsers to target, or undefined for all
663
+ * @param action - async function to execute on each browser
664
+ * @param options.timeout - per-browser timeout
665
+ * @param options.concurrency - 'heavy' | 'light' | number - type of operation or explicit limit
666
+ */
667
+ async executeParallel<T>(
668
+ browserIds: string[] | undefined,
669
+ action: (page: Page, browserId: string) => Promise<T>,
670
+ options: { timeout?: number; concurrency?: 'heavy' | 'light' | number } = {}
671
+ ): Promise<ParallelResults<T>> {
672
+ const start = Date.now();
673
+
674
+ // Auto-sync from API if no browsers connected
675
+ await this.ensureBrowsersLoaded();
676
+
677
+ const ids = browserIds ?? this.getBrowserIds();
678
+ const timeout = options.timeout ?? this.defaultTimeout;
679
+
680
+ // Get appropriate limiter based on operation type
681
+ const limiter = this.getLimiter(options.concurrency);
682
+
683
+ console.error(`[Executor] executeParallel: running on ${ids.length} browsers with concurrency limit: [${ids.join(', ')}]`);
684
+
685
+ const tasks = ids.map(async (id): Promise<ParallelResult<T>> => {
686
+ // Wait for slot in limiter - this prevents all 9 browsers from executing at once
687
+ return limiter.run(async () => {
688
+ const taskStart = Date.now();
689
+ const browser = this.browsers.get(id);
690
+
691
+ if (!browser) {
692
+ console.error(`[Executor] ❌ Browser ${id} not found in map`);
693
+ return {
694
+ browserId: id,
695
+ success: false,
696
+ error: `Browser ${id} not found`,
697
+ duration: Date.now() - taskStart,
698
+ };
699
+ }
700
+
701
+ // Check if browser is still connected before executing
702
+ if (!browser.browser.isConnected()) {
703
+ console.error(`[Executor] ❌ Browser ${id} is disconnected`);
704
+ this.browsers.delete(id);
705
+ return {
706
+ browserId: id,
707
+ success: false,
708
+ error: `Browser ${id} is disconnected`,
709
+ duration: Date.now() - taskStart,
710
+ };
711
+ }
712
+
713
+ try {
714
+ console.error(`[Executor] 🔄 Executing action on ${id}...`);
715
+
716
+ // Use AbortController pattern to properly cleanup timeout
717
+ let timeoutId: ReturnType<typeof setTimeout>;
718
+ const timeoutPromise = new Promise<never>((_, reject) => {
719
+ timeoutId = setTimeout(() => reject(new Error('Timeout')), timeout);
720
+ });
721
+
722
+ try {
723
+ const result = await Promise.race([
724
+ action(browser.page, id),
725
+ timeoutPromise,
726
+ ]);
727
+
728
+ clearTimeout(timeoutId!);
729
+ console.error(`[Executor] ✅ Action completed on ${id}`);
730
+ return {
731
+ browserId: id,
732
+ success: true,
733
+ result,
734
+ duration: Date.now() - taskStart,
735
+ };
736
+ } catch (error) {
737
+ clearTimeout(timeoutId!);
738
+ throw error;
739
+ }
740
+ } catch (error) {
741
+ console.error(`[Executor] ❌ Action failed on ${id}: ${error}`);
742
+ return {
743
+ browserId: id,
744
+ success: false,
745
+ error: String(error),
746
+ duration: Date.now() - taskStart,
747
+ };
748
+ }
749
+ });
750
+ });
751
+
752
+ const results = await Promise.all(tasks);
753
+
754
+ console.error(`[Executor] executeParallel complete: ${results.filter(r => r.success).length}/${results.length} succeeded in ${Date.now() - start}ms`);
755
+ return {
756
+ results,
757
+ totalDuration: Date.now() - start,
758
+ successCount: results.filter(r => r.success).length,
759
+ failureCount: results.filter(r => !r.success).length,
760
+ };
761
+ }
762
+
763
+ // ==================== NAVIGATION ====================
764
+
765
+ /**
766
+ * Navigate multiple browsers to the same URL.
767
+ * OPTIMIZATION: Uses higher concurrency for navigation (it's mostly network-bound)
768
+ */
769
+ async parallelNavigate(
770
+ url: string,
771
+ browserIds?: string[]
772
+ ): Promise<ParallelResults<{ url: string; title: string }>> {
773
+ return this.executeParallel(
774
+ browserIds,
775
+ async (page) => {
776
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
777
+ await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
778
+ return { url: page.url(), title: await page.title() };
779
+ },
780
+ {
781
+ timeout: 60000,
782
+ concurrency: 'light'
783
+ }
784
+ );
785
+ }
786
+
787
+ async parallelNavigateMulti(
788
+ targets: NavigationTarget[]
789
+ ): Promise<ParallelResults<{ url: string; title: string }>> {
790
+ const start = Date.now();
791
+
792
+ const tasks = targets.map(async (target): Promise<ParallelResult<{ url: string; title: string }>> => {
793
+ const taskStart = Date.now();
794
+ const browser = this.browsers.get(target.browserId);
795
+
796
+ if (!browser) {
797
+ return {
798
+ browserId: target.browserId,
799
+ success: false,
800
+ error: `Browser ${target.browserId} not found`,
801
+ duration: Date.now() - taskStart,
802
+ };
803
+ }
804
+
805
+ try {
806
+ await browser.page.goto(target.url, { waitUntil: 'domcontentloaded' });
807
+ await browser.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
808
+ return {
809
+ browserId: target.browserId,
810
+ success: true,
811
+ result: { url: browser.page.url(), title: await browser.page.title() },
812
+ duration: Date.now() - taskStart,
813
+ };
814
+ } catch (error) {
815
+ return {
816
+ browserId: target.browserId,
817
+ success: false,
818
+ error: String(error),
819
+ duration: Date.now() - taskStart,
820
+ };
821
+ }
822
+ });
823
+
824
+ const results = await Promise.all(tasks);
825
+
826
+ return {
827
+ results,
828
+ totalDuration: Date.now() - start,
829
+ successCount: results.filter(r => r.success).length,
830
+ failureCount: results.filter(r => !r.success).length,
831
+ };
832
+ }
833
+
834
+ async parallelGoBack(browserIds?: string[]): Promise<ParallelResults<void>> {
835
+ return this.executeParallel(browserIds, async (page) => {
836
+ await page.goBack();
837
+ });
838
+ }
839
+
840
+ async parallelGoForward(browserIds?: string[]): Promise<ParallelResults<void>> {
841
+ return this.executeParallel(browserIds, async (page) => {
842
+ await page.goForward();
843
+ });
844
+ }
845
+
846
+ async parallelReload(browserIds?: string[]): Promise<ParallelResults<void>> {
847
+ return this.executeParallel(browserIds, async (page) => {
848
+ await page.reload();
849
+ });
850
+ }
851
+
852
+ // ==================== SNAPSHOT ====================
853
+
854
+ async parallelSnapshot(
855
+ browserIds?: string[],
856
+ options: { compact?: boolean } = {}
857
+ ): Promise<ParallelResults<SnapshotResult>> {
858
+ const MAX_SNAPSHOT_SIZE = 30000;
859
+
860
+ return this.executeParallel(
861
+ browserIds,
862
+ async (page, browserId) => {
863
+ // Extract interactive elements with real HTML attributes for selector building
864
+ const elements = await page.evaluate((compactMode: boolean) => {
865
+ const interactiveTags = new Set(['a', 'button', 'input', 'select', 'textarea', 'summary']);
866
+ const interactiveRoles = new Set([
867
+ 'button', 'link', 'textbox', 'combobox', 'searchbox',
868
+ 'checkbox', 'radio', 'switch', 'slider', 'spinbutton',
869
+ 'menuitem', 'menuitemcheckbox', 'menuitemradio',
870
+ 'option', 'tab', 'treeitem', 'listbox',
871
+ ]);
872
+
873
+ interface ElementInfo {
874
+ tag: string;
875
+ selector: string;
876
+ text: string;
877
+ type?: string;
878
+ value?: string;
879
+ checked?: boolean;
880
+ disabled?: boolean;
881
+ href?: string;
882
+ placeholder?: string;
883
+ section?: string;
884
+ }
885
+
886
+ // Build the best unique selector for an element
887
+ function buildSelector(el: Element): string {
888
+ const tag = el.tagName.toLowerCase();
889
+ // 1. id — best
890
+ if (el.id) return `#${CSS.escape(el.id)}`;
891
+ // 2. data-testid
892
+ const testId = el.getAttribute('data-testid');
893
+ if (testId) return `[data-testid="${testId}"]`;
894
+ // 3. name attribute
895
+ const name = el.getAttribute('name');
896
+ if (name) return `${tag}[name="${name}"]`;
897
+ // 4. placeholder
898
+ const placeholder = el.getAttribute('placeholder');
899
+ if (placeholder) return `${tag}[placeholder="${placeholder}"]`;
900
+ // 5. aria-label
901
+ const ariaLabel = el.getAttribute('aria-label');
902
+ if (ariaLabel) return `[aria-label="${ariaLabel}"]`;
903
+ // 6. role + name
904
+ const role = el.getAttribute('role');
905
+ if (role) {
906
+ const text = el.textContent?.trim().slice(0, 40);
907
+ if (text) return `role=${role}[name="${text}"]`;
908
+ return `[role="${role}"]`;
909
+ }
910
+ // 7. tag + text content for buttons/links
911
+ if (tag === 'button' || tag === 'a') {
912
+ const text = el.textContent?.trim().slice(0, 40);
913
+ if (text) return `${tag}:has-text("${text}")`;
914
+ }
915
+ // 8. tag + type for inputs
916
+ const type = el.getAttribute('type');
917
+ if (tag === 'input' && type) return `input[type="${type}"]`;
918
+ // 9. class-based fallback
919
+ if (el.className && typeof el.className === 'string') {
920
+ const cls = el.className.trim().split(/\s+/)[0];
921
+ if (cls) return `${tag}.${CSS.escape(cls)}`;
922
+ }
923
+ return tag;
924
+ }
925
+
926
+ // Find nearest section/heading for context
927
+ function findSection(el: Element): string {
928
+ let node: Element | null = el;
929
+ while (node) {
930
+ // Check previous siblings and parent for headings
931
+ let sibling = node.previousElementSibling;
932
+ while (sibling) {
933
+ if (/^H[1-6]$/.test(sibling.tagName)) {
934
+ return sibling.textContent?.trim().slice(0, 50) || '';
935
+ }
936
+ sibling = sibling.previousElementSibling;
937
+ }
938
+ // Check parent landmarks
939
+ const role = node.getAttribute('role');
940
+ const ariaLabel = node.getAttribute('aria-label');
941
+ if (role === 'navigation' || role === 'main' || role === 'banner' ||
942
+ role === 'dialog' || role === 'form') {
943
+ return `[${role}${ariaLabel ? ': ' + ariaLabel : ''}]`;
944
+ }
945
+ if (node.tagName === 'NAV' || node.tagName === 'MAIN' ||
946
+ node.tagName === 'HEADER' || node.tagName === 'FOOTER' ||
947
+ node.tagName === 'FORM') {
948
+ const label = ariaLabel || node.getAttribute('name') || '';
949
+ return `[${node.tagName.toLowerCase()}${label ? ': ' + label : ''}]`;
950
+ }
951
+ node = node.parentElement;
952
+ }
953
+ return '';
954
+ }
955
+
956
+ const results: ElementInfo[] = [];
957
+ const seen = new Set<string>();
958
+
959
+ // Also collect headings for page structure context
960
+ if (!compactMode) {
961
+ const headings = Array.from(document.querySelectorAll('h1, h2, h3'));
962
+ for (const h of headings) {
963
+ const text = h.textContent?.trim().slice(0, 80);
964
+ if (text) {
965
+ results.push({
966
+ tag: h.tagName.toLowerCase(),
967
+ selector: '',
968
+ text: text,
969
+ });
970
+ }
971
+ }
972
+ }
973
+
974
+ const allElements = Array.from(document.querySelectorAll('*'));
975
+ for (const el of allElements) {
976
+ const tag = el.tagName.toLowerCase();
977
+ const role = el.getAttribute('role');
978
+ const isInteractive = interactiveTags.has(tag) ||
979
+ (role !== null && interactiveRoles.has(role)) ||
980
+ el.getAttribute('contenteditable') === 'true' ||
981
+ (el.getAttribute('tabindex') !== null && el.getAttribute('tabindex') !== '-1');
982
+
983
+ if (!isInteractive) continue;
984
+
985
+ // Skip hidden elements
986
+ const rect = el.getBoundingClientRect();
987
+ if (rect.width === 0 && rect.height === 0) continue;
988
+ const style = window.getComputedStyle(el);
989
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
990
+
991
+ const selector = buildSelector(el);
992
+ // Deduplicate by selector
993
+ if (seen.has(selector)) continue;
994
+ seen.add(selector);
995
+
996
+ const text = el.textContent?.trim().slice(0, 60) || '';
997
+ const info: ElementInfo = { tag, selector, text };
998
+
999
+ // Add useful attributes
1000
+ const type = el.getAttribute('type');
1001
+ if (type) info.type = type;
1002
+
1003
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
1004
+ if (el.value) info.value = el.value.slice(0, 60);
1005
+ }
1006
+ if (el instanceof HTMLInputElement && (el.type === 'checkbox' || el.type === 'radio')) {
1007
+ info.checked = el.checked;
1008
+ }
1009
+ if ((el as HTMLElement).getAttribute('disabled') !== null) {
1010
+ info.disabled = true;
1011
+ }
1012
+
1013
+ const href = el.getAttribute('href');
1014
+ if (href && href !== '#' && !href.startsWith('javascript:')) {
1015
+ info.href = href.slice(0, 100);
1016
+ }
1017
+
1018
+ const placeholder = el.getAttribute('placeholder');
1019
+ if (placeholder) info.placeholder = placeholder;
1020
+
1021
+ // Section context (only in full mode)
1022
+ if (!compactMode) {
1023
+ const section = findSection(el);
1024
+ if (section) info.section = section;
1025
+ }
1026
+
1027
+ results.push(info);
1028
+ }
1029
+
1030
+ return results;
1031
+ }, !!options.compact);
1032
+
1033
+ // Format elements into readable text
1034
+ let snapshot = this.formatSnapshot(elements, !!options.compact);
1035
+
1036
+ // Truncate if too large
1037
+ if (snapshot.length > MAX_SNAPSHOT_SIZE) {
1038
+ snapshot = snapshot.substring(0, MAX_SNAPSHOT_SIZE) +
1039
+ `\n\n... [TRUNCATED: ${elements.length} elements total, showing first ${MAX_SNAPSHOT_SIZE} chars]`;
1040
+ }
1041
+
1042
+ return {
1043
+ browserId,
1044
+ snapshot,
1045
+ url: page.url(),
1046
+ title: await page.title(),
1047
+ };
1048
+ },
1049
+ { concurrency: 'heavy' }
1050
+ );
1051
+ }
1052
+
1053
+ /**
1054
+ * Format extracted elements into readable snapshot text
1055
+ */
1056
+ private formatSnapshot(elements: Array<{
1057
+ tag: string; selector: string; text: string;
1058
+ type?: string; value?: string; checked?: boolean;
1059
+ disabled?: boolean; href?: string; placeholder?: string; section?: string;
1060
+ }>, compact: boolean): string {
1061
+ const lines: string[] = [];
1062
+ let lastSection = '';
1063
+
1064
+ for (const el of elements) {
1065
+ // Headings (structure context, full mode only)
1066
+ if (!el.selector && (el.tag === 'h1' || el.tag === 'h2' || el.tag === 'h3')) {
1067
+ lines.push(`\n## ${el.text}`);
1068
+ continue;
1069
+ }
1070
+
1071
+ // Section separator
1072
+ if (!compact && el.section && el.section !== lastSection) {
1073
+ lastSection = el.section;
1074
+ lines.push(`\n--- ${el.section} ---`);
1075
+ }
1076
+
1077
+ // Build element line
1078
+ let line = `- ${el.tag}`;
1079
+ if (el.type) line += `[type=${el.type}]`;
1080
+ if (el.disabled) line += ' (disabled)';
1081
+ line += ` → ${el.selector}`;
1082
+
1083
+ // Add context
1084
+ const details: string[] = [];
1085
+ if (el.placeholder) details.push(`placeholder="${el.placeholder}"`);
1086
+ if (el.value) details.push(`value="${el.value}"`);
1087
+ if (el.checked !== undefined) details.push(el.checked ? 'checked' : 'unchecked');
1088
+ if (el.text && el.text.length > 0) details.push(`"${el.text}"`);
1089
+ if (el.href) details.push(`→ ${el.href}`);
1090
+
1091
+ if (details.length > 0) {
1092
+ line += ` ${details.join(' | ')}`;
1093
+ }
1094
+
1095
+ lines.push(line);
1096
+ }
1097
+
1098
+ return lines.join('\n');
1099
+ }
1100
+
1101
+
1102
+ /**
1103
+ * Take screenshots from multiple browsers.
1104
+ * OPTIMIZATION: Uses JPEG format with quality 70% for ~5x smaller files.
1105
+ * For 9 browsers: ~4.5MB PNG → ~900KB JPEG
1106
+ */
1107
+ async parallelScreenshot(
1108
+ browserIds?: string[],
1109
+ options: { fullPage?: boolean; quality?: number; format?: 'png' | 'jpeg' } = {}
1110
+ ): Promise<ParallelResults<Buffer>> {
1111
+ // Use JPEG with quality 70% by default for much smaller files
1112
+ const format = options.format ?? 'jpeg';
1113
+ const quality = format === 'jpeg' ? (options.quality ?? 70) : undefined;
1114
+
1115
+ // Lower concurrency for heavy screenshot operations
1116
+ return this.executeParallel(
1117
+ browserIds,
1118
+ async (page) => {
1119
+ return await page.screenshot({
1120
+ fullPage: options.fullPage,
1121
+ type: format,
1122
+ quality,
1123
+ });
1124
+ },
1125
+ { concurrency: 'heavy' }
1126
+ );
1127
+ }
1128
+
1129
+
1130
+ // ==================== HUMAN-LIKE HELPERS ====================
1131
+
1132
+ /** Get or create a human profile for a browser */
1133
+ private getProfile(browserId: string): HumanProfile {
1134
+ let profile = this.profiles.get(browserId);
1135
+ if (!profile) {
1136
+ profile = generateProfile(browserId);
1137
+ this.profiles.set(browserId, profile);
1138
+ }
1139
+ return profile;
1140
+ }
1141
+
1142
+ /** Get last known mouse position, or a random starting position */
1143
+ private getMousePos(browserId: string): { x: number; y: number } {
1144
+ return this.lastMousePosition.get(browserId) ?? {
1145
+ x: 200 + Math.random() * 400,
1146
+ y: 150 + Math.random() * 300,
1147
+ };
1148
+ }
1149
+
1150
+ /** Update tracked mouse position */
1151
+ private setMousePos(browserId: string, pos: { x: number; y: number }): void {
1152
+ this.lastMousePosition.set(browserId, pos);
1153
+ }
1154
+
1155
+ // ==================== REF-BASED INTERACTION (human-like) ====================
1156
+
1157
+ async parallelClick(
1158
+ selector: string,
1159
+ browserIds?: string[]
1160
+ ): Promise<ParallelResults<void>> {
1161
+ return this.executeParallel(browserIds, async (page, browserId) => {
1162
+ const profile = this.getProfile(browserId);
1163
+ const locator = page.locator(selector);
1164
+ await locator.waitFor({ state: 'visible', timeout: 5000 });
1165
+
1166
+ await ensureCursor(page);
1167
+ await preActionDelay(profile);
1168
+
1169
+ const { target } = await getClickTarget(page, locator);
1170
+ const fromPos = this.getMousePos(browserId);
1171
+ await humanClick(page, target, fromPos, profile);
1172
+ this.setMousePos(browserId, target);
1173
+ });
1174
+ }
1175
+
1176
+ async parallelFill(
1177
+ selector: string,
1178
+ text: string,
1179
+ browserIds?: string[]
1180
+ ): Promise<ParallelResults<string>> {
1181
+ return this.executeParallel(browserIds, async (page, browserId) => {
1182
+ const profile = this.getProfile(browserId);
1183
+ const locator = page.locator(selector);
1184
+ await locator.waitFor({ state: 'visible', timeout: 5000 });
1185
+
1186
+ await ensureCursor(page);
1187
+ await preActionDelay(profile);
1188
+
1189
+ // Human-like click to focus
1190
+ const { target } = await getClickTarget(page, locator);
1191
+ const fromPos = this.getMousePos(browserId);
1192
+ await humanClick(page, target, fromPos, profile);
1193
+ this.setMousePos(browserId, target);
1194
+
1195
+ // Clear existing content
1196
+ await humanDelay(80, 200);
1197
+ await page.keyboard.press('Meta+a');
1198
+ await humanDelay(30, 80);
1199
+ await page.keyboard.press('Backspace');
1200
+ await humanDelay(50, 150);
1201
+
1202
+ // Type via CDP with human-like delays
1203
+ await humanTypeText(page, text, profile);
1204
+
1205
+ return `filled "${text}"`;
1206
+ });
1207
+ }
1208
+
1209
+ async parallelFillMulti(
1210
+ selector: string,
1211
+ texts: TextMapping
1212
+ ): Promise<ParallelResults<void>> {
1213
+ const browserIds = Object.keys(texts);
1214
+
1215
+ return this.executeParallel(browserIds, async (page, browserId) => {
1216
+ const text = texts[browserId];
1217
+ if (!text) throw new Error(`No text provided for browser ${browserId}`);
1218
+
1219
+ const profile = this.getProfile(browserId);
1220
+ const locator = page.locator(selector);
1221
+ await locator.waitFor({ state: 'visible', timeout: 5000 });
1222
+
1223
+ await ensureCursor(page);
1224
+ await preActionDelay(profile);
1225
+
1226
+ // Human-like click to focus
1227
+ const { target } = await getClickTarget(page, locator);
1228
+ const fromPos = this.getMousePos(browserId);
1229
+ await humanClick(page, target, fromPos, profile);
1230
+ this.setMousePos(browserId, target);
1231
+
1232
+ // Clear + type via CDP
1233
+ await humanDelay(80, 200);
1234
+ await page.keyboard.press('Meta+a');
1235
+ await humanDelay(30, 80);
1236
+ await page.keyboard.press('Backspace');
1237
+ await humanDelay(50, 150);
1238
+
1239
+ await humanTypeText(page, text, profile);
1240
+ });
1241
+ }
1242
+
1243
+ async parallelType(
1244
+ selector: string,
1245
+ text: string,
1246
+ browserIds?: string[]
1247
+ ): Promise<ParallelResults<void>> {
1248
+ return this.executeParallel(browserIds, async (page, browserId) => {
1249
+ const profile = this.getProfile(browserId);
1250
+ const locator = page.locator(selector);
1251
+ await locator.waitFor({ state: 'visible', timeout: 5000 });
1252
+
1253
+ await ensureCursor(page);
1254
+
1255
+ // Click to focus with human-like movement
1256
+ const { target } = await getClickTarget(page, locator);
1257
+ const fromPos = this.getMousePos(browserId);
1258
+ await humanClick(page, target, fromPos, profile);
1259
+ this.setMousePos(browserId, target);
1260
+
1261
+ await humanDelay(50, 150);
1262
+
1263
+ // Type via CDP with human-like delays
1264
+ await humanTypeText(page, text, profile);
1265
+ });
1266
+ }
1267
+
1268
+ async parallelHover(
1269
+ selector: string,
1270
+ browserIds?: string[]
1271
+ ): Promise<ParallelResults<void>> {
1272
+ return this.executeParallel(browserIds, async (page, browserId) => {
1273
+ const profile = this.getProfile(browserId);
1274
+ const locator = page.locator(selector);
1275
+ await locator.waitFor({ state: 'visible', timeout: 5000 });
1276
+
1277
+ await ensureCursor(page);
1278
+ await preActionDelay(profile);
1279
+
1280
+ const { target } = await getClickTarget(page, locator);
1281
+ const fromPos = this.getMousePos(browserId);
1282
+ await humanMouseMove(page, fromPos, target, profile);
1283
+ this.setMousePos(browserId, target);
1284
+ });
1285
+ }
1286
+
1287
+ async parallelSelectOption(
1288
+ selector: string,
1289
+ values: string[],
1290
+ browserIds?: string[]
1291
+ ): Promise<ParallelResults<string[]>> {
1292
+ return this.executeParallel(browserIds, async (page, browserId) => {
1293
+ const profile = this.getProfile(browserId);
1294
+ const locator = page.locator(selector);
1295
+ await locator.waitFor({ state: 'visible', timeout: 5000 });
1296
+
1297
+ await ensureCursor(page);
1298
+ await preActionDelay(profile);
1299
+
1300
+ // Human-like click to open dropdown
1301
+ const { target } = await getClickTarget(page, locator);
1302
+ const fromPos = this.getMousePos(browserId);
1303
+ await humanClick(page, target, fromPos, profile);
1304
+ this.setMousePos(browserId, target);
1305
+
1306
+ await humanDelay(200, 500);
1307
+
1308
+ return await locator.selectOption(values);
1309
+ });
1310
+ }
1311
+
1312
+ async parallelPressKey(
1313
+ key: string,
1314
+ browserIds?: string[]
1315
+ ): Promise<ParallelResults<void>> {
1316
+ return this.executeParallel(browserIds, async (page, browserId) => {
1317
+ await preActionDelay(this.getProfile(browserId));
1318
+ await page.keyboard.press(key);
1319
+ });
1320
+ }
1321
+
1322
+ async parallelClickCoordinates(
1323
+ coordinates: Record<string, { x: number; y: number }>,
1324
+ options: { center?: number; timeout?: number; fromViewport?: boolean } = {}
1325
+ ): Promise<ParallelResults<{ clickedAt: { x: number; y: number }; documentY: number; scrollY: number }>> {
1326
+ const browserIds = Object.keys(coordinates);
1327
+
1328
+ return this.executeParallel(
1329
+ browserIds,
1330
+ async (page, browserId) => {
1331
+ const profile = this.getProfile(browserId);
1332
+ const target = coordinates[browserId];
1333
+ if (!target) {
1334
+ throw new Error(`No coordinates provided for browser ${browserId}`);
1335
+ }
1336
+
1337
+ const viewport = page.viewportSize();
1338
+ if (!viewport) {
1339
+ throw new Error('Viewport size is not available for the page');
1340
+ }
1341
+
1342
+ await ensureCursor(page);
1343
+ await preActionDelay(profile);
1344
+
1345
+ if (options.fromViewport !== false) {
1346
+ const clickX = Math.max(0, Math.min(Math.round(target.x), viewport.width - 1));
1347
+ const clickY = Math.max(0, Math.min(Math.round(target.y), viewport.height - 1));
1348
+
1349
+ const fromPos = this.getMousePos(browserId);
1350
+ await humanClick(page, { x: clickX, y: clickY }, fromPos, profile);
1351
+ this.setMousePos(browserId, { x: clickX, y: clickY });
1352
+
1353
+ const scrollY = await page.evaluate(() => (globalThis as any).scrollY ?? 0);
1354
+ return {
1355
+ clickedAt: { x: clickX, y: clickY },
1356
+ documentY: scrollY + clickY,
1357
+ scrollY,
1358
+ };
1359
+ } else {
1360
+ const desiredCenter = options.center ?? viewport.height / 2;
1361
+ const desiredScroll = Math.max(0, Math.floor(target.y - desiredCenter));
1362
+
1363
+ await page.evaluate((scrollY) => {
1364
+ (globalThis as any).scrollTo?.(0, scrollY);
1365
+ }, desiredScroll);
1366
+ const actualScroll = await page.evaluate(() => (globalThis as any).scrollY ?? 0);
1367
+
1368
+ const clickX = Math.max(0, Math.min(Math.round(target.x), viewport.width - 1));
1369
+ const clickY = Math.round(target.y - actualScroll);
1370
+
1371
+ if (clickY < 0 || clickY > viewport.height) {
1372
+ throw new Error(
1373
+ `Target Y=${target.y} is outside viewport after scrolling (scrollY=${actualScroll}, viewportHeight=${viewport.height})`
1374
+ );
1375
+ }
1376
+
1377
+ const fromPos = this.getMousePos(browserId);
1378
+ await humanClick(page, { x: clickX, y: clickY }, fromPos, profile);
1379
+ this.setMousePos(browserId, { x: clickX, y: clickY });
1380
+
1381
+ return {
1382
+ clickedAt: { x: clickX, y: clickY },
1383
+ documentY: target.y,
1384
+ scrollY: actualScroll,
1385
+ };
1386
+ }
1387
+ },
1388
+ { timeout: options.timeout ?? this.defaultTimeout }
1389
+ );
1390
+ }
1391
+
1392
+ /**
1393
+ * Click at center of normalized bounding box (from OmniParser output)
1394
+ */
1395
+ async parallelClickNormalizedBox(
1396
+ box: [number, number, number, number],
1397
+ browserIds: string[],
1398
+ options: { button?: 'left' | 'right' | 'middle'; clickCount?: number; delay?: number; timeout?: number } = {}
1399
+ ): Promise<ParallelResults<{ absoluteX: number; absoluteY: number; viewport: { width: number; height: number } }>> {
1400
+ const [x1, y1, x2, y2] = box;
1401
+
1402
+ if (x1 < 0 || x1 > 1 || x2 < 0 || x2 > 1 || y1 < 0 || y1 > 1 || y2 < 0 || y2 > 1) {
1403
+ throw new Error('Coordinates must be normalized (0-1 range)');
1404
+ }
1405
+
1406
+ const normalizedX = (x1 + x2) / 2;
1407
+ const normalizedY = (y1 + y2) / 2;
1408
+
1409
+ return this.executeParallel(
1410
+ browserIds,
1411
+ async (page, browserId) => {
1412
+ const profile = this.getProfile(browserId);
1413
+ const viewport = page.viewportSize();
1414
+ if (!viewport) {
1415
+ throw new Error('Viewport size is not available');
1416
+ }
1417
+
1418
+ const absoluteX = Math.round(normalizedX * viewport.width);
1419
+ const absoluteY = Math.round(normalizedY * viewport.height);
1420
+
1421
+ await ensureCursor(page);
1422
+ await preActionDelay(profile);
1423
+
1424
+ const fromPos = this.getMousePos(browserId);
1425
+ await humanMouseMove(page, fromPos, { x: absoluteX, y: absoluteY }, profile);
1426
+
1427
+ await page.mouse.down({ button: options.button || 'left' });
1428
+ await humanDelay(50, 120);
1429
+ await page.mouse.up({ button: options.button || 'left' });
1430
+
1431
+ if (options.clickCount && options.clickCount > 1) {
1432
+ for (let i = 1; i < options.clickCount; i++) {
1433
+ await humanDelay(80, 150);
1434
+ await page.mouse.down({ button: options.button || 'left' });
1435
+ await humanDelay(30, 80);
1436
+ await page.mouse.up({ button: options.button || 'left' });
1437
+ }
1438
+ }
1439
+
1440
+ this.setMousePos(browserId, { x: absoluteX, y: absoluteY });
1441
+ return { absoluteX, absoluteY, viewport };
1442
+ },
1443
+ { timeout: options.timeout ?? this.defaultTimeout }
1444
+ );
1445
+ }
1446
+
1447
+ // ==================== CODE EXECUTION ====================
1448
+
1449
+ async parallelRunCode(
1450
+ code: string,
1451
+ browserIds?: string[]
1452
+ ): Promise<ParallelResults<unknown>> {
1453
+ return this.executeParallel(browserIds, async (page) => {
1454
+ const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
1455
+ const fn = new AsyncFunction('page', code);
1456
+ return await fn(page);
1457
+ });
1458
+ }
1459
+
1460
+ async parallelRunCodeWithVariables(
1461
+ codeTemplate: string,
1462
+ variables: VariableMapping
1463
+ ): Promise<ParallelResults<unknown>> {
1464
+ const browserIds = Object.keys(variables);
1465
+
1466
+ return this.executeParallel(browserIds, async (page, browserId) => {
1467
+ const vars = variables[browserId] || {};
1468
+
1469
+ let code = codeTemplate;
1470
+ for (const [key, value] of Object.entries(vars)) {
1471
+ code = code.replace(new RegExp(`{{${key}}}`, 'g'), value);
1472
+ }
1473
+
1474
+ const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
1475
+ const fn = new AsyncFunction('page', code);
1476
+ return await fn(page);
1477
+ });
1478
+ }
1479
+
1480
+ async parallelEvaluate(
1481
+ script: string,
1482
+ browserIds?: string[]
1483
+ ): Promise<ParallelResults<unknown>> {
1484
+ return this.executeParallel(browserIds, async (page) => {
1485
+ return await page.evaluate(script);
1486
+ });
1487
+ }
1488
+
1489
+ // ==================== WAITING ====================
1490
+
1491
+ async parallelWaitFor(
1492
+ options: { time?: number; text?: string; textGone?: string },
1493
+ browserIds?: string[]
1494
+ ): Promise<ParallelResults<void>> {
1495
+ // Calculate timeout: for time-based waits, use the wait time + buffer;
1496
+ // for text waits, use 30s default and pass it to Playwright's waitFor
1497
+ const waitTimeMs = options.time ? options.time * 1000 : 0;
1498
+ const textTimeout = 30000;
1499
+ const timeout = waitTimeMs + textTimeout + 5000; // buffer for overhead
1500
+
1501
+ return this.executeParallel(browserIds, async (page) => {
1502
+ if (options.time) {
1503
+ await page.waitForTimeout(options.time * 1000);
1504
+ }
1505
+ if (options.text) {
1506
+ await page.getByText(options.text).waitFor({ timeout: textTimeout });
1507
+ }
1508
+ if (options.textGone) {
1509
+ await page.getByText(options.textGone).waitFor({ state: 'hidden', timeout: textTimeout });
1510
+ }
1511
+ }, { timeout });
1512
+ }
1513
+
1514
+ // ==================== FILE OPERATIONS (downloads, uploads, drag&drop) ====================
1515
+
1516
+ /** Cached project files directory path */
1517
+ private projectFilesDir: string | null | undefined = undefined;
1518
+
1519
+ /** Fetch project files directory from API (cached) */
1520
+ async getProjectFilesDir(): Promise<string | null> {
1521
+ if (this.projectFilesDir !== undefined) return this.projectFilesDir;
1522
+
1523
+ const apiUrl = process.env.API_SERVER_URL;
1524
+ const userId = process.env.USER_ID;
1525
+ const deviceId = process.env.DEVICE_ID;
1526
+
1527
+ if (!apiUrl || !userId) {
1528
+ this.projectFilesDir = null;
1529
+ return null;
1530
+ }
1531
+
1532
+ try {
1533
+ const params = new URLSearchParams({ userId });
1534
+ if (deviceId) params.set('deviceId', deviceId);
1535
+ const resp = await fetch(`${apiUrl}/api/project-path?${params}`);
1536
+ const data = await resp.json() as { localPath: string | null };
1537
+ this.projectFilesDir = data.localPath;
1538
+ console.error(`[Executor] Project files dir: ${this.projectFilesDir}`);
1539
+ return this.projectFilesDir;
1540
+ } catch (e) {
1541
+ console.error(`[Executor] Failed to fetch project path:`, e);
1542
+ this.projectFilesDir = null;
1543
+ return null;
1544
+ }
1545
+ }
1546
+
1547
+ /** Configure browser to download files to project directory */
1548
+ async setupDownloads(
1549
+ browserIds?: string[]
1550
+ ): Promise<ParallelResults<string>> {
1551
+ const dir = await this.getProjectFilesDir();
1552
+ if (!dir) {
1553
+ throw new Error('Project files directory not available — file relay may not be connected');
1554
+ }
1555
+
1556
+ return this.executeParallel(browserIds, async (page) => {
1557
+ const cdp = await page.context().newCDPSession(page);
1558
+ try {
1559
+ await cdp.send('Page.setDownloadBehavior', {
1560
+ behavior: 'allow',
1561
+ downloadPath: dir,
1562
+ });
1563
+ } finally {
1564
+ await cdp.detach().catch(() => {});
1565
+ }
1566
+ return `Downloads → ${dir}`;
1567
+ });
1568
+ }
1569
+
1570
+ /** List files in the project directory */
1571
+ async listProjectFiles(): Promise<string[]> {
1572
+ const dir = await this.getProjectFilesDir();
1573
+ if (!dir) return [];
1574
+
1575
+ const ids = this.getBrowserIds();
1576
+ if (ids.length === 0) return [];
1577
+
1578
+ const browser = this.browsers.get(ids[0]);
1579
+ if (!browser) return [];
1580
+
1581
+ try {
1582
+ const cdp = await browser.page.context().newCDPSession(browser.page);
1583
+ try {
1584
+ const result = await cdp.send('Runtime.evaluate', {
1585
+ expression: `
1586
+ (() => {
1587
+ try {
1588
+ const fs = require('fs');
1589
+ const path = require('path');
1590
+ const dir = ${JSON.stringify(dir)};
1591
+ if (!fs.existsSync(dir)) return JSON.stringify([]);
1592
+ const files = fs.readdirSync(dir).filter(f => !f.startsWith('.'));
1593
+ return JSON.stringify(files.map(f => {
1594
+ const stat = fs.statSync(path.join(dir, f));
1595
+ return { name: f, size: stat.size, modified: stat.mtimeMs };
1596
+ }));
1597
+ } catch(e) { return JSON.stringify({ error: e.message }); }
1598
+ })()
1599
+ `,
1600
+ returnByValue: true,
1601
+ });
1602
+
1603
+ const value = result.result?.value;
1604
+ if (typeof value === 'string') {
1605
+ return JSON.parse(value);
1606
+ }
1607
+ } finally {
1608
+ await cdp.detach().catch(() => {});
1609
+ }
1610
+ } catch {
1611
+ // fallback — return empty
1612
+ }
1613
+ return [];
1614
+ }
1615
+
1616
+ /** Upload a file from project directory via filechooser (selector-based) */
1617
+ async parallelUploadFile(
1618
+ selector: string,
1619
+ fileName: string,
1620
+ browserIds?: string[]
1621
+ ): Promise<ParallelResults<string>> {
1622
+ const dir = await this.getProjectFilesDir();
1623
+ if (!dir) {
1624
+ throw new Error('Project files directory not available');
1625
+ }
1626
+
1627
+ return this.executeParallel(browserIds, async (page, browserId) => {
1628
+ const profile = this.getProfile(browserId);
1629
+ const locator = page.locator(selector);
1630
+ await locator.waitFor({ state: 'visible', timeout: 5000 });
1631
+
1632
+ await ensureCursor(page);
1633
+ await preActionDelay(profile);
1634
+
1635
+ const fileChooserPromise = page.waitForEvent('filechooser', { timeout: 10000 });
1636
+
1637
+ const { target } = await getClickTarget(page, locator);
1638
+ const fromPos = this.getMousePos(browserId);
1639
+ await humanClick(page, target, fromPos, profile);
1640
+ this.setMousePos(browserId, target);
1641
+
1642
+ const fileChooser = await fileChooserPromise;
1643
+ const filePath = `${dir}/${fileName}`;
1644
+ await fileChooser.setFiles(filePath);
1645
+
1646
+ return `Uploaded "${fileName}" from ${dir}`;
1647
+ }, { timeout: 15000 });
1648
+ }
1649
+
1650
+ // ==================== BATCH FORM FILL ====================
1651
+
1652
+ async parallelFillForm(
1653
+ fields: Array<{ selector: string; value: string }>,
1654
+ browserIds?: string[]
1655
+ ): Promise<ParallelResults<string>> {
1656
+ return this.executeParallel(browserIds, async (page, browserId) => {
1657
+ const profile = this.getProfile(browserId);
1658
+ const filled: string[] = [];
1659
+
1660
+ for (const field of fields) {
1661
+ const locator = page.locator(field.selector);
1662
+ await locator.waitFor({ state: 'visible', timeout: 5000 });
1663
+
1664
+ await ensureCursor(page);
1665
+ await preActionDelay(profile);
1666
+
1667
+ // Human-like click to focus
1668
+ const { target } = await getClickTarget(page, locator);
1669
+ const fromPos = this.getMousePos(browserId);
1670
+ await humanClick(page, target, fromPos, profile);
1671
+ this.setMousePos(browserId, target);
1672
+
1673
+ // Clear existing content
1674
+ await humanDelay(80, 200);
1675
+ await page.keyboard.press('Meta+a');
1676
+ await humanDelay(30, 80);
1677
+ await page.keyboard.press('Backspace');
1678
+ await humanDelay(50, 150);
1679
+
1680
+ // Type value
1681
+ await humanTypeText(page, field.value, profile);
1682
+ filled.push(`${field.selector}="${field.value}"`);
1683
+ }
1684
+
1685
+ return `Filled ${filled.length} fields: ${filled.join(', ')}`;
1686
+ }, { timeout: fields.length * 5000 + 5000 });
1687
+ }
1688
+
1689
+ // ==================== ELEMENT DRAG & DROP ====================
1690
+
1691
+ async parallelDrag(
1692
+ startSelector: string,
1693
+ endSelector: string,
1694
+ browserIds?: string[]
1695
+ ): Promise<ParallelResults<string>> {
1696
+ return this.executeParallel(browserIds, async (page, browserId) => {
1697
+ const profile = this.getProfile(browserId);
1698
+
1699
+ const startLocator = page.locator(startSelector);
1700
+ const endLocator = page.locator(endSelector);
1701
+ await startLocator.waitFor({ state: 'visible', timeout: 5000 });
1702
+ await endLocator.waitFor({ state: 'visible', timeout: 5000 });
1703
+
1704
+ await ensureCursor(page);
1705
+ await preActionDelay(profile);
1706
+
1707
+ const { target: startPos } = await getClickTarget(page, startLocator);
1708
+ const { target: endPos } = await getClickTarget(page, endLocator);
1709
+ const fromPos = this.getMousePos(browserId);
1710
+
1711
+ // Move to start element
1712
+ await humanMouseMove(page, fromPos, startPos, profile);
1713
+ await humanDelay(50, 150);
1714
+
1715
+ // Mouse down on start
1716
+ await page.mouse.down();
1717
+ await humanDelay(100, 250);
1718
+
1719
+ // Move to end element (drag)
1720
+ await humanMouseMove(page, startPos, endPos, profile);
1721
+ await humanDelay(50, 150);
1722
+
1723
+ // Mouse up on end (drop)
1724
+ await page.mouse.up();
1725
+ this.setMousePos(browserId, endPos);
1726
+
1727
+ return `Dragged "${startSelector}" → "${endSelector}"`;
1728
+ }, { timeout: 15000 });
1729
+ }
1730
+
1731
+ // ==================== TAB MANAGEMENT ====================
1732
+
1733
+ async parallelTabs(
1734
+ action: 'list' | 'new' | 'close' | 'select',
1735
+ options: { index?: number; url?: string } = {},
1736
+ browserIds?: string[]
1737
+ ): Promise<ParallelResults<unknown>> {
1738
+ return this.executeParallel(browserIds, async (page, browserId) => {
1739
+ const browser = this.browsers.get(browserId);
1740
+ if (!browser) throw new Error(`Browser ${browserId} not found`);
1741
+
1742
+ const context = browser.context;
1743
+ const pages = context.pages();
1744
+
1745
+ switch (action) {
1746
+ case 'list': {
1747
+ return pages.map((p, i) => ({
1748
+ index: i,
1749
+ url: p.url(),
1750
+ active: p === browser.page,
1751
+ }));
1752
+ }
1753
+ case 'new': {
1754
+ const newPage = await context.newPage();
1755
+ if (options.url) {
1756
+ await newPage.goto(options.url, { waitUntil: 'domcontentloaded' });
1757
+ }
1758
+ browser.page = newPage;
1759
+ newPage.setDefaultTimeout(this.defaultTimeout);
1760
+ newPage.setDefaultNavigationTimeout(60000);
1761
+ this._setupPageTracking(browserId, newPage);
1762
+ return { index: context.pages().length - 1, url: newPage.url() };
1763
+ }
1764
+ case 'close': {
1765
+ const idx = options.index ?? pages.indexOf(browser.page);
1766
+ if (idx < 0 || idx >= pages.length) throw new Error(`Tab index ${idx} out of range (0-${pages.length - 1})`);
1767
+ const targetPage = pages[idx];
1768
+ await targetPage.close();
1769
+ if (targetPage === browser.page) {
1770
+ const remaining = context.pages();
1771
+ if (remaining.length > 0) {
1772
+ browser.page = remaining[Math.min(idx, remaining.length - 1)];
1773
+ }
1774
+ }
1775
+ return { closed: idx, remaining: context.pages().length };
1776
+ }
1777
+ case 'select': {
1778
+ if (options.index === undefined) throw new Error('index required for select action');
1779
+ const currentPages = context.pages();
1780
+ if (options.index < 0 || options.index >= currentPages.length) {
1781
+ throw new Error(`Tab index ${options.index} out of range (0-${currentPages.length - 1})`);
1782
+ }
1783
+ browser.page = currentPages[options.index];
1784
+ await browser.page.bringToFront();
1785
+ return { selected: options.index, url: browser.page.url() };
1786
+ }
1787
+ }
1788
+ }, { timeout: 30000 });
1789
+ }
1790
+
1791
+ /** Set up console/network tracking on a new page */
1792
+ private _setupPageTracking(browserId: string, page: Page): void {
1793
+ const consoleLog = this.consoleMessages.get(browserId);
1794
+ if (consoleLog) {
1795
+ page.on('console', (msg) => {
1796
+ consoleLog.push({ type: msg.type(), text: msg.text(), timestamp: Date.now() });
1797
+ if (consoleLog.length > 1000) consoleLog.shift();
1798
+ });
1799
+ }
1800
+ const networkLog = this.networkRequests.get(browserId);
1801
+ if (networkLog) {
1802
+ page.on('response', (response) => {
1803
+ const req = response.request();
1804
+ networkLog.push({
1805
+ url: req.url(),
1806
+ method: req.method(),
1807
+ status: response.status(),
1808
+ resourceType: req.resourceType(),
1809
+ timestamp: Date.now(),
1810
+ });
1811
+ if (networkLog.length > 1000) networkLog.shift();
1812
+ });
1813
+ }
1814
+ }
1815
+
1816
+ // ==================== DIALOG HANDLING ====================
1817
+
1818
+ async parallelHandleDialog(
1819
+ accept: boolean,
1820
+ promptText?: string,
1821
+ browserIds?: string[]
1822
+ ): Promise<ParallelResults<string>> {
1823
+ return this.executeParallel(browserIds, async (page) => {
1824
+ return new Promise<string>((resolve, reject) => {
1825
+ const timeout = setTimeout(() => {
1826
+ reject(new Error('No dialog appeared within 10 seconds'));
1827
+ }, 10000);
1828
+
1829
+ page.once('dialog', async (dialog) => {
1830
+ clearTimeout(timeout);
1831
+ const type = dialog.type();
1832
+ const message = dialog.message();
1833
+ try {
1834
+ if (accept) {
1835
+ await dialog.accept(promptText);
1836
+ } else {
1837
+ await dialog.dismiss();
1838
+ }
1839
+ resolve(`${accept ? 'Accepted' : 'Dismissed'} ${type} dialog: "${message}"`);
1840
+ } catch (e) {
1841
+ reject(e);
1842
+ }
1843
+ });
1844
+ });
1845
+ }, { timeout: 15000 });
1846
+ }
1847
+
1848
+ // ==================== CONSOLE MESSAGES ====================
1849
+
1850
+ async parallelConsoleMessages(
1851
+ onlyErrors?: boolean,
1852
+ browserIds?: string[]
1853
+ ): Promise<ParallelResults<Array<{ type: string; text: string; timestamp: number }>>> {
1854
+ await this.ensureBrowsersLoaded();
1855
+ const ids = browserIds ?? this.getBrowserIds();
1856
+
1857
+ const results = ids.map((id) => {
1858
+ let messages = this.consoleMessages.get(id) || [];
1859
+ if (onlyErrors) {
1860
+ messages = messages.filter(m => m.type === 'error');
1861
+ }
1862
+ return {
1863
+ browserId: id,
1864
+ success: true as const,
1865
+ result: [...messages],
1866
+ duration: 0,
1867
+ };
1868
+ });
1869
+
1870
+ return {
1871
+ results,
1872
+ totalDuration: 0,
1873
+ successCount: results.length,
1874
+ failureCount: 0,
1875
+ };
1876
+ }
1877
+
1878
+ // ==================== NETWORK REQUESTS ====================
1879
+
1880
+ async parallelNetworkRequests(
1881
+ browserIds?: string[]
1882
+ ): Promise<ParallelResults<Array<{ url: string; method: string; status?: number; resourceType: string; timestamp: number }>>> {
1883
+ await this.ensureBrowsersLoaded();
1884
+ const ids = browserIds ?? this.getBrowserIds();
1885
+
1886
+ const results = ids.map((id) => {
1887
+ const requests = this.networkRequests.get(id) || [];
1888
+ return {
1889
+ browserId: id,
1890
+ success: true as const,
1891
+ result: [...requests],
1892
+ duration: 0,
1893
+ };
1894
+ });
1895
+
1896
+ return {
1897
+ results,
1898
+ totalDuration: 0,
1899
+ successCount: results.length,
1900
+ failureCount: 0,
1901
+ };
1902
+ }
1903
+
1904
+ /** Drag and drop a file onto a dropzone (selector-based) */
1905
+ async parallelDragDropFile(
1906
+ selector: string,
1907
+ fileName: string,
1908
+ browserIds?: string[]
1909
+ ): Promise<ParallelResults<string>> {
1910
+ const dir = await this.getProjectFilesDir();
1911
+ if (!dir) {
1912
+ throw new Error('Project files directory not available');
1913
+ }
1914
+
1915
+ return this.executeParallel(browserIds, async (page, browserId) => {
1916
+ const profile = this.getProfile(browserId);
1917
+ const locator = page.locator(selector);
1918
+ await locator.waitFor({ state: 'visible', timeout: 5000 });
1919
+
1920
+ await ensureCursor(page);
1921
+ await preActionDelay(profile);
1922
+
1923
+ const { target } = await getClickTarget(page, locator);
1924
+ const fromPos = this.getMousePos(browserId);
1925
+ await humanMouseMove(page, fromPos, target, profile);
1926
+ this.setMousePos(browserId, target);
1927
+
1928
+ const filePath = `${dir}/${fileName}`;
1929
+
1930
+ await page.evaluate(async ({ x, y, fileName, filePath }: { x: number; y: number; fileName: string; filePath: string }) => {
1931
+ const dropzone = document.elementFromPoint(x, y);
1932
+ if (!dropzone) throw new Error('No element at drop coordinates');
1933
+
1934
+ const dataTransfer = new DataTransfer();
1935
+ try {
1936
+ const resp = await fetch(`file://${filePath}`);
1937
+ const blob = await resp.blob();
1938
+ const file = new File([blob], fileName, { type: blob.type });
1939
+ dataTransfer.items.add(file);
1940
+ } catch {
1941
+ const file = new File([''], fileName);
1942
+ dataTransfer.items.add(file);
1943
+ }
1944
+
1945
+ const events = ['dragenter', 'dragover', 'drop'];
1946
+ for (const eventType of events) {
1947
+ const event = new DragEvent(eventType, {
1948
+ bubbles: true,
1949
+ cancelable: true,
1950
+ dataTransfer,
1951
+ });
1952
+ dropzone.dispatchEvent(event);
1953
+ await new Promise(r => setTimeout(r, 50));
1954
+ }
1955
+ }, { x: target.x, y: target.y, fileName, filePath });
1956
+
1957
+ return `Dropped "${fileName}" onto element`;
1958
+ }, { timeout: 15000 });
1959
+ }
1960
+ }