428 lines
11 KiB
TypeScript
428 lines
11 KiB
TypeScript
import { chromium, firefox, webkit, devices, type Browser, type BrowserContext, type Page, type Frame, type Dialog, type Request, type Route } from 'playwright';
|
|
import type { LaunchCommand } from './types.js';
|
|
|
|
interface TrackedRequest {
|
|
url: string;
|
|
method: string;
|
|
headers: Record<string, string>;
|
|
timestamp: number;
|
|
resourceType: string;
|
|
}
|
|
|
|
/**
|
|
* Manages the Playwright browser lifecycle with multiple tabs/windows
|
|
*/
|
|
export class BrowserManager {
|
|
private browser: Browser | null = null;
|
|
private contexts: BrowserContext[] = [];
|
|
private pages: Page[] = [];
|
|
private activePageIndex: number = 0;
|
|
private activeFrame: Frame | null = null;
|
|
private dialogHandler: ((dialog: Dialog) => Promise<void>) | null = null;
|
|
private trackedRequests: TrackedRequest[] = [];
|
|
private routes: Map<string, (route: Route) => Promise<void>> = new Map();
|
|
|
|
/**
|
|
* Check if browser is launched
|
|
*/
|
|
isLaunched(): boolean {
|
|
return this.browser !== null;
|
|
}
|
|
|
|
/**
|
|
* Get the current active page, throws if not launched
|
|
*/
|
|
getPage(): Page {
|
|
if (this.pages.length === 0) {
|
|
throw new Error('Browser not launched. Call launch first.');
|
|
}
|
|
return this.pages[this.activePageIndex];
|
|
}
|
|
|
|
/**
|
|
* Get the current frame (or page's main frame if no frame is selected)
|
|
*/
|
|
getFrame(): Frame {
|
|
if (this.activeFrame) {
|
|
return this.activeFrame;
|
|
}
|
|
return this.getPage().mainFrame();
|
|
}
|
|
|
|
/**
|
|
* Switch to a frame by selector, name, or URL
|
|
*/
|
|
async switchToFrame(options: { selector?: string; name?: string; url?: string }): Promise<void> {
|
|
const page = this.getPage();
|
|
|
|
if (options.selector) {
|
|
const frameElement = await page.$(options.selector);
|
|
if (!frameElement) {
|
|
throw new Error(`Frame not found: ${options.selector}`);
|
|
}
|
|
const frame = await frameElement.contentFrame();
|
|
if (!frame) {
|
|
throw new Error(`Element is not a frame: ${options.selector}`);
|
|
}
|
|
this.activeFrame = frame;
|
|
} else if (options.name) {
|
|
const frame = page.frame({ name: options.name });
|
|
if (!frame) {
|
|
throw new Error(`Frame not found with name: ${options.name}`);
|
|
}
|
|
this.activeFrame = frame;
|
|
} else if (options.url) {
|
|
const frame = page.frame({ url: options.url });
|
|
if (!frame) {
|
|
throw new Error(`Frame not found with URL: ${options.url}`);
|
|
}
|
|
this.activeFrame = frame;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Switch back to main frame
|
|
*/
|
|
switchToMainFrame(): void {
|
|
this.activeFrame = null;
|
|
}
|
|
|
|
/**
|
|
* Set up dialog handler
|
|
*/
|
|
setDialogHandler(response: 'accept' | 'dismiss', promptText?: string): void {
|
|
const page = this.getPage();
|
|
|
|
// Remove existing handler if any
|
|
if (this.dialogHandler) {
|
|
page.removeListener('dialog', this.dialogHandler);
|
|
}
|
|
|
|
this.dialogHandler = async (dialog: Dialog) => {
|
|
if (response === 'accept') {
|
|
await dialog.accept(promptText);
|
|
} else {
|
|
await dialog.dismiss();
|
|
}
|
|
};
|
|
|
|
page.on('dialog', this.dialogHandler);
|
|
}
|
|
|
|
/**
|
|
* Clear dialog handler
|
|
*/
|
|
clearDialogHandler(): void {
|
|
if (this.dialogHandler) {
|
|
const page = this.getPage();
|
|
page.removeListener('dialog', this.dialogHandler);
|
|
this.dialogHandler = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start tracking requests
|
|
*/
|
|
startRequestTracking(): void {
|
|
const page = this.getPage();
|
|
page.on('request', (request: Request) => {
|
|
this.trackedRequests.push({
|
|
url: request.url(),
|
|
method: request.method(),
|
|
headers: request.headers(),
|
|
timestamp: Date.now(),
|
|
resourceType: request.resourceType(),
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get tracked requests
|
|
*/
|
|
getRequests(filter?: string): TrackedRequest[] {
|
|
if (filter) {
|
|
return this.trackedRequests.filter(r => r.url.includes(filter));
|
|
}
|
|
return this.trackedRequests;
|
|
}
|
|
|
|
/**
|
|
* Clear tracked requests
|
|
*/
|
|
clearRequests(): void {
|
|
this.trackedRequests = [];
|
|
}
|
|
|
|
/**
|
|
* Add a route to intercept requests
|
|
*/
|
|
async addRoute(
|
|
url: string,
|
|
options: {
|
|
response?: { status?: number; body?: string; contentType?: string; headers?: Record<string, string> };
|
|
abort?: boolean;
|
|
}
|
|
): Promise<void> {
|
|
const page = this.getPage();
|
|
|
|
const handler = async (route: Route) => {
|
|
if (options.abort) {
|
|
await route.abort();
|
|
} else if (options.response) {
|
|
await route.fulfill({
|
|
status: options.response.status ?? 200,
|
|
body: options.response.body ?? '',
|
|
contentType: options.response.contentType ?? 'text/plain',
|
|
headers: options.response.headers,
|
|
});
|
|
} else {
|
|
await route.continue();
|
|
}
|
|
};
|
|
|
|
this.routes.set(url, handler);
|
|
await page.route(url, handler);
|
|
}
|
|
|
|
/**
|
|
* Remove a route
|
|
*/
|
|
async removeRoute(url?: string): Promise<void> {
|
|
const page = this.getPage();
|
|
|
|
if (url) {
|
|
const handler = this.routes.get(url);
|
|
if (handler) {
|
|
await page.unroute(url, handler);
|
|
this.routes.delete(url);
|
|
}
|
|
} else {
|
|
// Remove all routes
|
|
for (const [routeUrl, handler] of this.routes) {
|
|
await page.unroute(routeUrl, handler);
|
|
}
|
|
this.routes.clear();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set geolocation
|
|
*/
|
|
async setGeolocation(latitude: number, longitude: number, accuracy?: number): Promise<void> {
|
|
const context = this.contexts[0];
|
|
if (context) {
|
|
await context.setGeolocation({ latitude, longitude, accuracy });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set permissions
|
|
*/
|
|
async setPermissions(permissions: string[], grant: boolean): Promise<void> {
|
|
const context = this.contexts[0];
|
|
if (context) {
|
|
if (grant) {
|
|
await context.grantPermissions(permissions);
|
|
} else {
|
|
await context.clearPermissions();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set viewport
|
|
*/
|
|
async setViewport(width: number, height: number): Promise<void> {
|
|
const page = this.getPage();
|
|
await page.setViewportSize({ width, height });
|
|
}
|
|
|
|
/**
|
|
* Get device descriptor
|
|
*/
|
|
getDevice(deviceName: string): typeof devices[keyof typeof devices] | undefined {
|
|
return devices[deviceName as keyof typeof devices];
|
|
}
|
|
|
|
/**
|
|
* List available devices
|
|
*/
|
|
listDevices(): string[] {
|
|
return Object.keys(devices);
|
|
}
|
|
|
|
/**
|
|
* Get all pages
|
|
*/
|
|
getPages(): Page[] {
|
|
return this.pages;
|
|
}
|
|
|
|
/**
|
|
* Get current page index
|
|
*/
|
|
getActiveIndex(): number {
|
|
return this.activePageIndex;
|
|
}
|
|
|
|
/**
|
|
* Get the current browser instance
|
|
*/
|
|
getBrowser(): Browser | null {
|
|
return this.browser;
|
|
}
|
|
|
|
/**
|
|
* Launch the browser with the specified options
|
|
*/
|
|
async launch(options: LaunchCommand): Promise<void> {
|
|
// Close existing browser if any
|
|
if (this.browser) {
|
|
await this.close();
|
|
}
|
|
|
|
// Select browser type
|
|
const browserType = options.browser ?? 'chromium';
|
|
const launcher = browserType === 'firefox'
|
|
? firefox
|
|
: browserType === 'webkit'
|
|
? webkit
|
|
: chromium;
|
|
|
|
// Launch browser
|
|
this.browser = await launcher.launch({
|
|
headless: options.headless ?? true,
|
|
});
|
|
|
|
// Create context with viewport
|
|
const context = await this.browser.newContext({
|
|
viewport: options.viewport ?? { width: 1280, height: 720 },
|
|
});
|
|
this.contexts.push(context);
|
|
|
|
// Create initial page
|
|
const page = await context.newPage();
|
|
this.pages.push(page);
|
|
this.activePageIndex = 0;
|
|
}
|
|
|
|
/**
|
|
* Create a new tab in the current context
|
|
*/
|
|
async newTab(): Promise<{ index: number; total: number }> {
|
|
if (!this.browser || this.contexts.length === 0) {
|
|
throw new Error('Browser not launched');
|
|
}
|
|
|
|
const context = this.contexts[0]; // Use first context for tabs
|
|
const page = await context.newPage();
|
|
this.pages.push(page);
|
|
this.activePageIndex = this.pages.length - 1;
|
|
|
|
return { index: this.activePageIndex, total: this.pages.length };
|
|
}
|
|
|
|
/**
|
|
* Create a new window (new context)
|
|
*/
|
|
async newWindow(viewport?: { width: number; height: number }): Promise<{ index: number; total: number }> {
|
|
if (!this.browser) {
|
|
throw new Error('Browser not launched');
|
|
}
|
|
|
|
const context = await this.browser.newContext({
|
|
viewport: viewport ?? { width: 1280, height: 720 },
|
|
});
|
|
this.contexts.push(context);
|
|
|
|
const page = await context.newPage();
|
|
this.pages.push(page);
|
|
this.activePageIndex = this.pages.length - 1;
|
|
|
|
return { index: this.activePageIndex, total: this.pages.length };
|
|
}
|
|
|
|
/**
|
|
* Switch to a specific tab/page by index
|
|
*/
|
|
switchTo(index: number): { index: number; url: string; title: string } {
|
|
if (index < 0 || index >= this.pages.length) {
|
|
throw new Error(`Invalid tab index: ${index}. Available: 0-${this.pages.length - 1}`);
|
|
}
|
|
|
|
this.activePageIndex = index;
|
|
const page = this.pages[index];
|
|
|
|
return {
|
|
index: this.activePageIndex,
|
|
url: page.url(),
|
|
title: '', // Title requires async, will be fetched separately
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Close a specific tab/page
|
|
*/
|
|
async closeTab(index?: number): Promise<{ closed: number; remaining: number }> {
|
|
const targetIndex = index ?? this.activePageIndex;
|
|
|
|
if (targetIndex < 0 || targetIndex >= this.pages.length) {
|
|
throw new Error(`Invalid tab index: ${targetIndex}`);
|
|
}
|
|
|
|
if (this.pages.length === 1) {
|
|
throw new Error('Cannot close the last tab. Use "close" to close the browser.');
|
|
}
|
|
|
|
const page = this.pages[targetIndex];
|
|
await page.close();
|
|
this.pages.splice(targetIndex, 1);
|
|
|
|
// Adjust active index if needed
|
|
if (this.activePageIndex >= this.pages.length) {
|
|
this.activePageIndex = this.pages.length - 1;
|
|
} else if (this.activePageIndex > targetIndex) {
|
|
this.activePageIndex--;
|
|
}
|
|
|
|
return { closed: targetIndex, remaining: this.pages.length };
|
|
}
|
|
|
|
/**
|
|
* List all tabs with their info
|
|
*/
|
|
async listTabs(): Promise<Array<{ index: number; url: string; title: string; active: boolean }>> {
|
|
const tabs = await Promise.all(
|
|
this.pages.map(async (page, index) => ({
|
|
index,
|
|
url: page.url(),
|
|
title: await page.title().catch(() => ''),
|
|
active: index === this.activePageIndex,
|
|
}))
|
|
);
|
|
return tabs;
|
|
}
|
|
|
|
/**
|
|
* Close the browser and clean up
|
|
*/
|
|
async close(): Promise<void> {
|
|
for (const page of this.pages) {
|
|
await page.close().catch(() => {});
|
|
}
|
|
this.pages = [];
|
|
|
|
for (const context of this.contexts) {
|
|
await context.close().catch(() => {});
|
|
}
|
|
this.contexts = [];
|
|
|
|
if (this.browser) {
|
|
await this.browser.close().catch(() => {});
|
|
this.browser = null;
|
|
}
|
|
|
|
this.activePageIndex = 0;
|
|
}
|
|
}
|