agent-browser 0.3.1 → 0.3.3

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/src/browser.ts DELETED
@@ -1,686 +0,0 @@
1
- import {
2
- chromium,
3
- firefox,
4
- webkit,
5
- devices,
6
- type Browser,
7
- type BrowserContext,
8
- type Page,
9
- type Frame,
10
- type Dialog,
11
- type Request,
12
- type Route,
13
- type Locator,
14
- } from 'playwright-core';
15
- import type { LaunchCommand } from './types.js';
16
- import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
17
-
18
- interface TrackedRequest {
19
- url: string;
20
- method: string;
21
- headers: Record<string, string>;
22
- timestamp: number;
23
- resourceType: string;
24
- }
25
-
26
- interface ConsoleMessage {
27
- type: string;
28
- text: string;
29
- timestamp: number;
30
- }
31
-
32
- interface PageError {
33
- message: string;
34
- timestamp: number;
35
- }
36
-
37
- /**
38
- * Manages the Playwright browser lifecycle with multiple tabs/windows
39
- */
40
- export class BrowserManager {
41
- private browser: Browser | null = null;
42
- private contexts: BrowserContext[] = [];
43
- private pages: Page[] = [];
44
- private activePageIndex: number = 0;
45
- private activeFrame: Frame | null = null;
46
- private dialogHandler: ((dialog: Dialog) => Promise<void>) | null = null;
47
- private trackedRequests: TrackedRequest[] = [];
48
- private routes: Map<string, (route: Route) => Promise<void>> = new Map();
49
- private consoleMessages: ConsoleMessage[] = [];
50
- private pageErrors: PageError[] = [];
51
- private isRecordingHar: boolean = false;
52
- private refMap: RefMap = {};
53
- private lastSnapshot: string = '';
54
-
55
- /**
56
- * Check if browser is launched
57
- */
58
- isLaunched(): boolean {
59
- return this.browser !== null;
60
- }
61
-
62
- /**
63
- * Get enhanced snapshot with refs and cache the ref map
64
- */
65
- async getSnapshot(options?: {
66
- interactive?: boolean;
67
- maxDepth?: number;
68
- compact?: boolean;
69
- selector?: string;
70
- }): Promise<EnhancedSnapshot> {
71
- const page = this.getPage();
72
- const snapshot = await getEnhancedSnapshot(page, options);
73
- this.refMap = snapshot.refs;
74
- this.lastSnapshot = snapshot.tree;
75
- return snapshot;
76
- }
77
-
78
- /**
79
- * Get the cached ref map from last snapshot
80
- */
81
- getRefMap(): RefMap {
82
- return this.refMap;
83
- }
84
-
85
- /**
86
- * Get a locator from a ref (e.g., "e1", "@e1", "ref=e1")
87
- * Returns null if ref doesn't exist or is invalid
88
- */
89
- getLocatorFromRef(refArg: string): Locator | null {
90
- const ref = parseRef(refArg);
91
- if (!ref) return null;
92
-
93
- const refData = this.refMap[ref];
94
- if (!refData) return null;
95
-
96
- const page = this.getPage();
97
-
98
- // Parse the selector and create locator
99
- if (refData.name) {
100
- return page.getByRole(refData.role as any, { name: refData.name });
101
- } else {
102
- return page.getByRole(refData.role as any);
103
- }
104
- }
105
-
106
- /**
107
- * Check if a selector looks like a ref
108
- */
109
- isRef(selector: string): boolean {
110
- return parseRef(selector) !== null;
111
- }
112
-
113
- /**
114
- * Get locator - supports both refs and regular selectors
115
- */
116
- getLocator(selectorOrRef: string): Locator {
117
- // Check if it's a ref first
118
- const locator = this.getLocatorFromRef(selectorOrRef);
119
- if (locator) return locator;
120
-
121
- // Otherwise treat as regular selector
122
- const page = this.getPage();
123
- return page.locator(selectorOrRef);
124
- }
125
-
126
- /**
127
- * Get the current active page, throws if not launched
128
- */
129
- getPage(): Page {
130
- if (this.pages.length === 0) {
131
- throw new Error('Browser not launched. Call launch first.');
132
- }
133
- return this.pages[this.activePageIndex];
134
- }
135
-
136
- /**
137
- * Get the current frame (or page's main frame if no frame is selected)
138
- */
139
- getFrame(): Frame {
140
- if (this.activeFrame) {
141
- return this.activeFrame;
142
- }
143
- return this.getPage().mainFrame();
144
- }
145
-
146
- /**
147
- * Switch to a frame by selector, name, or URL
148
- */
149
- async switchToFrame(options: { selector?: string; name?: string; url?: string }): Promise<void> {
150
- const page = this.getPage();
151
-
152
- if (options.selector) {
153
- const frameElement = await page.$(options.selector);
154
- if (!frameElement) {
155
- throw new Error(`Frame not found: ${options.selector}`);
156
- }
157
- const frame = await frameElement.contentFrame();
158
- if (!frame) {
159
- throw new Error(`Element is not a frame: ${options.selector}`);
160
- }
161
- this.activeFrame = frame;
162
- } else if (options.name) {
163
- const frame = page.frame({ name: options.name });
164
- if (!frame) {
165
- throw new Error(`Frame not found with name: ${options.name}`);
166
- }
167
- this.activeFrame = frame;
168
- } else if (options.url) {
169
- const frame = page.frame({ url: options.url });
170
- if (!frame) {
171
- throw new Error(`Frame not found with URL: ${options.url}`);
172
- }
173
- this.activeFrame = frame;
174
- }
175
- }
176
-
177
- /**
178
- * Switch back to main frame
179
- */
180
- switchToMainFrame(): void {
181
- this.activeFrame = null;
182
- }
183
-
184
- /**
185
- * Set up dialog handler
186
- */
187
- setDialogHandler(response: 'accept' | 'dismiss', promptText?: string): void {
188
- const page = this.getPage();
189
-
190
- // Remove existing handler if any
191
- if (this.dialogHandler) {
192
- page.removeListener('dialog', this.dialogHandler);
193
- }
194
-
195
- this.dialogHandler = async (dialog: Dialog) => {
196
- if (response === 'accept') {
197
- await dialog.accept(promptText);
198
- } else {
199
- await dialog.dismiss();
200
- }
201
- };
202
-
203
- page.on('dialog', this.dialogHandler);
204
- }
205
-
206
- /**
207
- * Clear dialog handler
208
- */
209
- clearDialogHandler(): void {
210
- if (this.dialogHandler) {
211
- const page = this.getPage();
212
- page.removeListener('dialog', this.dialogHandler);
213
- this.dialogHandler = null;
214
- }
215
- }
216
-
217
- /**
218
- * Start tracking requests
219
- */
220
- startRequestTracking(): void {
221
- const page = this.getPage();
222
- page.on('request', (request: Request) => {
223
- this.trackedRequests.push({
224
- url: request.url(),
225
- method: request.method(),
226
- headers: request.headers(),
227
- timestamp: Date.now(),
228
- resourceType: request.resourceType(),
229
- });
230
- });
231
- }
232
-
233
- /**
234
- * Get tracked requests
235
- */
236
- getRequests(filter?: string): TrackedRequest[] {
237
- if (filter) {
238
- return this.trackedRequests.filter((r) => r.url.includes(filter));
239
- }
240
- return this.trackedRequests;
241
- }
242
-
243
- /**
244
- * Clear tracked requests
245
- */
246
- clearRequests(): void {
247
- this.trackedRequests = [];
248
- }
249
-
250
- /**
251
- * Add a route to intercept requests
252
- */
253
- async addRoute(
254
- url: string,
255
- options: {
256
- response?: {
257
- status?: number;
258
- body?: string;
259
- contentType?: string;
260
- headers?: Record<string, string>;
261
- };
262
- abort?: boolean;
263
- }
264
- ): Promise<void> {
265
- const page = this.getPage();
266
-
267
- const handler = async (route: Route) => {
268
- if (options.abort) {
269
- await route.abort();
270
- } else if (options.response) {
271
- await route.fulfill({
272
- status: options.response.status ?? 200,
273
- body: options.response.body ?? '',
274
- contentType: options.response.contentType ?? 'text/plain',
275
- headers: options.response.headers,
276
- });
277
- } else {
278
- await route.continue();
279
- }
280
- };
281
-
282
- this.routes.set(url, handler);
283
- await page.route(url, handler);
284
- }
285
-
286
- /**
287
- * Remove a route
288
- */
289
- async removeRoute(url?: string): Promise<void> {
290
- const page = this.getPage();
291
-
292
- if (url) {
293
- const handler = this.routes.get(url);
294
- if (handler) {
295
- await page.unroute(url, handler);
296
- this.routes.delete(url);
297
- }
298
- } else {
299
- // Remove all routes
300
- for (const [routeUrl, handler] of this.routes) {
301
- await page.unroute(routeUrl, handler);
302
- }
303
- this.routes.clear();
304
- }
305
- }
306
-
307
- /**
308
- * Set geolocation
309
- */
310
- async setGeolocation(latitude: number, longitude: number, accuracy?: number): Promise<void> {
311
- const context = this.contexts[0];
312
- if (context) {
313
- await context.setGeolocation({ latitude, longitude, accuracy });
314
- }
315
- }
316
-
317
- /**
318
- * Set permissions
319
- */
320
- async setPermissions(permissions: string[], grant: boolean): Promise<void> {
321
- const context = this.contexts[0];
322
- if (context) {
323
- if (grant) {
324
- await context.grantPermissions(permissions);
325
- } else {
326
- await context.clearPermissions();
327
- }
328
- }
329
- }
330
-
331
- /**
332
- * Set viewport
333
- */
334
- async setViewport(width: number, height: number): Promise<void> {
335
- const page = this.getPage();
336
- await page.setViewportSize({ width, height });
337
- }
338
-
339
- /**
340
- * Get device descriptor
341
- */
342
- getDevice(deviceName: string): (typeof devices)[keyof typeof devices] | undefined {
343
- return devices[deviceName as keyof typeof devices];
344
- }
345
-
346
- /**
347
- * List available devices
348
- */
349
- listDevices(): string[] {
350
- return Object.keys(devices);
351
- }
352
-
353
- /**
354
- * Start console message tracking
355
- */
356
- startConsoleTracking(): void {
357
- const page = this.getPage();
358
- page.on('console', (msg) => {
359
- this.consoleMessages.push({
360
- type: msg.type(),
361
- text: msg.text(),
362
- timestamp: Date.now(),
363
- });
364
- });
365
- }
366
-
367
- /**
368
- * Get console messages
369
- */
370
- getConsoleMessages(): ConsoleMessage[] {
371
- return this.consoleMessages;
372
- }
373
-
374
- /**
375
- * Clear console messages
376
- */
377
- clearConsoleMessages(): void {
378
- this.consoleMessages = [];
379
- }
380
-
381
- /**
382
- * Start error tracking
383
- */
384
- startErrorTracking(): void {
385
- const page = this.getPage();
386
- page.on('pageerror', (error) => {
387
- this.pageErrors.push({
388
- message: error.message,
389
- timestamp: Date.now(),
390
- });
391
- });
392
- }
393
-
394
- /**
395
- * Get page errors
396
- */
397
- getPageErrors(): PageError[] {
398
- return this.pageErrors;
399
- }
400
-
401
- /**
402
- * Clear page errors
403
- */
404
- clearPageErrors(): void {
405
- this.pageErrors = [];
406
- }
407
-
408
- /**
409
- * Start HAR recording
410
- */
411
- async startHarRecording(): Promise<void> {
412
- // HAR is started at context level, flag for tracking
413
- this.isRecordingHar = true;
414
- }
415
-
416
- /**
417
- * Check if HAR recording
418
- */
419
- isHarRecording(): boolean {
420
- return this.isRecordingHar;
421
- }
422
-
423
- /**
424
- * Set offline mode
425
- */
426
- async setOffline(offline: boolean): Promise<void> {
427
- const context = this.contexts[0];
428
- if (context) {
429
- await context.setOffline(offline);
430
- }
431
- }
432
-
433
- /**
434
- * Set extra HTTP headers
435
- */
436
- async setExtraHeaders(headers: Record<string, string>): Promise<void> {
437
- const context = this.contexts[0];
438
- if (context) {
439
- await context.setExtraHTTPHeaders(headers);
440
- }
441
- }
442
-
443
- /**
444
- * Start tracing
445
- */
446
- async startTracing(options: { screenshots?: boolean; snapshots?: boolean }): Promise<void> {
447
- const context = this.contexts[0];
448
- if (context) {
449
- await context.tracing.start({
450
- screenshots: options.screenshots ?? true,
451
- snapshots: options.snapshots ?? true,
452
- });
453
- }
454
- }
455
-
456
- /**
457
- * Stop tracing and save
458
- */
459
- async stopTracing(path: string): Promise<void> {
460
- const context = this.contexts[0];
461
- if (context) {
462
- await context.tracing.stop({ path });
463
- }
464
- }
465
-
466
- /**
467
- * Save storage state (cookies, localStorage, etc.)
468
- */
469
- async saveStorageState(path: string): Promise<void> {
470
- const context = this.contexts[0];
471
- if (context) {
472
- await context.storageState({ path });
473
- }
474
- }
475
-
476
- /**
477
- * Get all pages
478
- */
479
- getPages(): Page[] {
480
- return this.pages;
481
- }
482
-
483
- /**
484
- * Get current page index
485
- */
486
- getActiveIndex(): number {
487
- return this.activePageIndex;
488
- }
489
-
490
- /**
491
- * Get the current browser instance
492
- */
493
- getBrowser(): Browser | null {
494
- return this.browser;
495
- }
496
-
497
- /**
498
- * Launch the browser with the specified options
499
- * If already launched, this is a no-op (browser stays open)
500
- */
501
- async launch(options: LaunchCommand): Promise<void> {
502
- // If already launched, don't relaunch
503
- if (this.browser) {
504
- return;
505
- }
506
-
507
- // Select browser type
508
- const browserType = options.browser ?? 'chromium';
509
- const launcher =
510
- browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
511
-
512
- // Launch browser
513
- this.browser = await launcher.launch({
514
- headless: options.headless ?? true,
515
- });
516
-
517
- // Create context with viewport
518
- const context = await this.browser.newContext({
519
- viewport: options.viewport ?? { width: 1280, height: 720 },
520
- });
521
-
522
- // Set default timeout to 10 seconds (Playwright default is 30s)
523
- context.setDefaultTimeout(10000);
524
-
525
- this.contexts.push(context);
526
-
527
- // Create initial page
528
- const page = await context.newPage();
529
- this.pages.push(page);
530
- this.activePageIndex = 0;
531
-
532
- // Automatically start console and error tracking
533
- this.setupPageTracking(page);
534
- }
535
-
536
- /**
537
- * Set up console and error tracking for a page
538
- */
539
- private setupPageTracking(page: Page): void {
540
- page.on('console', (msg) => {
541
- this.consoleMessages.push({
542
- type: msg.type(),
543
- text: msg.text(),
544
- timestamp: Date.now(),
545
- });
546
- });
547
-
548
- page.on('pageerror', (error) => {
549
- this.pageErrors.push({
550
- message: error.message,
551
- timestamp: Date.now(),
552
- });
553
- });
554
- }
555
-
556
- /**
557
- * Create a new tab in the current context
558
- */
559
- async newTab(): Promise<{ index: number; total: number }> {
560
- if (!this.browser || this.contexts.length === 0) {
561
- throw new Error('Browser not launched');
562
- }
563
-
564
- const context = this.contexts[0]; // Use first context for tabs
565
- const page = await context.newPage();
566
- this.pages.push(page);
567
- this.activePageIndex = this.pages.length - 1;
568
-
569
- // Set up tracking for the new page
570
- this.setupPageTracking(page);
571
-
572
- return { index: this.activePageIndex, total: this.pages.length };
573
- }
574
-
575
- /**
576
- * Create a new window (new context)
577
- */
578
- async newWindow(viewport?: {
579
- width: number;
580
- height: number;
581
- }): Promise<{ index: number; total: number }> {
582
- if (!this.browser) {
583
- throw new Error('Browser not launched');
584
- }
585
-
586
- const context = await this.browser.newContext({
587
- viewport: viewport ?? { width: 1280, height: 720 },
588
- });
589
- context.setDefaultTimeout(10000);
590
- this.contexts.push(context);
591
-
592
- const page = await context.newPage();
593
- this.pages.push(page);
594
- this.activePageIndex = this.pages.length - 1;
595
-
596
- // Set up tracking for the new page
597
- this.setupPageTracking(page);
598
-
599
- return { index: this.activePageIndex, total: this.pages.length };
600
- }
601
-
602
- /**
603
- * Switch to a specific tab/page by index
604
- */
605
- switchTo(index: number): { index: number; url: string; title: string } {
606
- if (index < 0 || index >= this.pages.length) {
607
- throw new Error(`Invalid tab index: ${index}. Available: 0-${this.pages.length - 1}`);
608
- }
609
-
610
- this.activePageIndex = index;
611
- const page = this.pages[index];
612
-
613
- return {
614
- index: this.activePageIndex,
615
- url: page.url(),
616
- title: '', // Title requires async, will be fetched separately
617
- };
618
- }
619
-
620
- /**
621
- * Close a specific tab/page
622
- */
623
- async closeTab(index?: number): Promise<{ closed: number; remaining: number }> {
624
- const targetIndex = index ?? this.activePageIndex;
625
-
626
- if (targetIndex < 0 || targetIndex >= this.pages.length) {
627
- throw new Error(`Invalid tab index: ${targetIndex}`);
628
- }
629
-
630
- if (this.pages.length === 1) {
631
- throw new Error('Cannot close the last tab. Use "close" to close the browser.');
632
- }
633
-
634
- const page = this.pages[targetIndex];
635
- await page.close();
636
- this.pages.splice(targetIndex, 1);
637
-
638
- // Adjust active index if needed
639
- if (this.activePageIndex >= this.pages.length) {
640
- this.activePageIndex = this.pages.length - 1;
641
- } else if (this.activePageIndex > targetIndex) {
642
- this.activePageIndex--;
643
- }
644
-
645
- return { closed: targetIndex, remaining: this.pages.length };
646
- }
647
-
648
- /**
649
- * List all tabs with their info
650
- */
651
- async listTabs(): Promise<Array<{ index: number; url: string; title: string; active: boolean }>> {
652
- const tabs = await Promise.all(
653
- this.pages.map(async (page, index) => ({
654
- index,
655
- url: page.url(),
656
- title: await page.title().catch(() => ''),
657
- active: index === this.activePageIndex,
658
- }))
659
- );
660
- return tabs;
661
- }
662
-
663
- /**
664
- * Close the browser and clean up
665
- */
666
- async close(): Promise<void> {
667
- for (const page of this.pages) {
668
- await page.close().catch(() => {});
669
- }
670
- this.pages = [];
671
-
672
- for (const context of this.contexts) {
673
- await context.close().catch(() => {});
674
- }
675
- this.contexts = [];
676
-
677
- if (this.browser) {
678
- await this.browser.close().catch(() => {});
679
- this.browser = null;
680
- }
681
-
682
- this.activePageIndex = 0;
683
- this.refMap = {};
684
- this.lastSnapshot = '';
685
- }
686
- }