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.
- package/README.md +27 -0
- package/dist/index.js +2 -0
- package/executor/concurrency.ts +118 -0
- package/executor/human-like.ts +523 -0
- package/executor/multi-browser.ts +1960 -0
- package/executor/snapshot-helpers.ts +88 -0
- package/executor/types.ts +128 -0
- package/package.json +36 -0
|
@@ -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
|
+
}
|