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