Files
gridpilot.gg/packages/infrastructure/adapters/automation/core/PlaywrightBrowserSession.ts
2025-11-30 02:07:08 +01:00

268 lines
8.5 KiB
TypeScript

import { Browser, BrowserContext, Page } from 'playwright';
import { chromium } from 'playwright-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import * as fs from 'fs';
import * as path from 'path';
import type { ILogger } from '../../../../application/ports/ILogger';
import { BrowserModeConfigLoader, BrowserMode } from '../../../config/BrowserModeConfig';
import { getAutomationMode } from '../../../config/AutomationConfig';
import type { PlaywrightConfig } from './PlaywrightAutomationAdapter';
import { PlaywrightAutomationAdapter } from './PlaywrightAutomationAdapter';
chromium.use(StealthPlugin());
export type BrowserModeSource = string;
export class PlaywrightBrowserSession {
private browser: Browser | null = null;
private persistentContext: BrowserContext | null = null;
private context: BrowserContext | null = null;
private page: Page | null = null;
private connected = false;
private isConnecting = false;
private browserModeLoader: BrowserModeConfigLoader;
private actualBrowserMode: BrowserMode;
private browserModeSource: BrowserModeSource;
constructor(
private readonly config: Required<PlaywrightConfig>,
private readonly logger?: ILogger,
browserModeLoader?: BrowserModeConfigLoader,
) {
const automationMode = getAutomationMode();
this.browserModeLoader = browserModeLoader ?? new BrowserModeConfigLoader();
const browserModeConfig = this.browserModeLoader.load();
this.actualBrowserMode = browserModeConfig.mode;
this.browserModeSource = browserModeConfig.source as BrowserModeSource;
this.log('info', 'Browser mode configured', {
mode: this.actualBrowserMode,
source: this.browserModeSource,
automationMode,
configHeadless: this.config.headless,
});
}
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
if (!this.logger) {
return;
}
const logger: any = this.logger;
logger[level](message, context as any);
}
private isRealMode(): boolean {
return this.config.mode === 'real';
}
getBrowserMode(): BrowserMode {
return this.actualBrowserMode;
}
getBrowserModeSource(): BrowserModeSource {
return this.browserModeSource;
}
getUserDataDir(): string {
return this.config.userDataDir;
}
getPage(): Page | null {
return this.page;
}
getContext(): BrowserContext | null {
return this.context;
}
getPersistentContext(): BrowserContext | null {
return this.persistentContext;
}
getBrowser(): Browser | null {
return this.browser;
}
isConnected(): boolean {
return this.connected && this.page !== null;
}
async connect(forceHeaded: boolean = false): Promise<{ success: boolean; error?: string }> {
if (this.connected && this.page) {
this.log('debug', 'Already connected, reusing existing connection');
return { success: true };
}
if (this.isConnecting) {
this.log('debug', 'Connection in progress, waiting...');
await new Promise(resolve => setTimeout(resolve, 100));
return this.connect(forceHeaded);
}
this.isConnecting = true;
try {
const currentConfig = this.browserModeLoader.load();
this.actualBrowserMode = currentConfig.mode;
this.browserModeSource = currentConfig.source as BrowserModeSource;
const effectiveMode = forceHeaded ? 'headed' : currentConfig.mode;
const adapterAny = PlaywrightAutomationAdapter as any;
const launcher = adapterAny.testLauncher ?? chromium;
this.log('debug', 'Effective browser mode at connect', {
effectiveMode,
actualBrowserMode: this.actualBrowserMode,
browserModeSource: this.browserModeSource,
forced: forceHeaded,
});
if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development') {
try {
const loaderValue = this.browserModeLoader && typeof this.browserModeLoader.load === 'function'
? this.browserModeLoader.load()
: undefined;
console.debug('[TEST-INSTRUMENT] PlaywrightAutomationAdapter.connect()', {
effectiveMode,
forceHeaded,
loaderValue,
browserModeSource: this.getBrowserModeSource(),
});
} catch {
// ignore instrumentation errors
}
}
if (this.isRealMode() && this.config.userDataDir) {
this.log('info', 'Launching persistent browser context', {
userDataDir: this.config.userDataDir,
mode: effectiveMode,
forced: forceHeaded,
});
if (!fs.existsSync(this.config.userDataDir)) {
fs.mkdirSync(this.config.userDataDir, { recursive: true });
}
await this.cleanupStaleLockFile(this.config.userDataDir);
this.persistentContext = await launcher.launchPersistentContext(
this.config.userDataDir,
{
headless: effectiveMode === 'headless',
args: [
'--disable-blink-features=AutomationControlled',
'--disable-features=IsolateOrigins,site-per-process',
],
ignoreDefaultArgs: ['--enable-automation'],
viewport: { width: 1920, height: 1080 },
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
);
const persistentContext = this.persistentContext!;
this.page = persistentContext.pages()[0] || await persistentContext.newPage();
this.page.setDefaultTimeout(this.config.timeout ?? 10000);
this.connected = true;
return { success: true };
}
this.browser = await launcher.launch({
headless: effectiveMode === 'headless',
args: [
'--disable-blink-features=AutomationControlled',
'--disable-features=IsolateOrigins,site-per-process',
],
ignoreDefaultArgs: ['--enable-automation'],
});
const browser = this.browser!;
this.context = await browser.newContext();
this.page = await this.context.newPage();
this.page.setDefaultTimeout(this.config.timeout ?? 10000);
this.connected = true;
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
} finally {
this.isConnecting = false;
}
}
async ensureBrowserContext(forceHeaded: boolean = false): Promise<void> {
const result = await this.connect(forceHeaded);
if (!result.success) {
throw new Error(result.error || 'Failed to connect browser');
}
}
private async cleanupStaleLockFile(userDataDir: string): Promise<void> {
const singletonLockPath = path.join(userDataDir, 'SingletonLock');
try {
if (!fs.existsSync(singletonLockPath)) {
return;
}
this.log('info', 'Found existing SingletonLock, attempting cleanup', { path: singletonLockPath });
fs.unlinkSync(singletonLockPath);
this.log('info', 'Cleaned up stale SingletonLock file');
} catch (error) {
this.log('warn', 'Could not clean up SingletonLock', { error: String(error) });
}
}
async disconnect(): Promise<void> {
if (this.page) {
await this.page.close();
this.page = null;
}
if (this.persistentContext) {
await this.persistentContext.close();
this.persistentContext = null;
}
if (this.context) {
await this.context.close();
this.context = null;
}
if (this.browser) {
await this.browser.close();
this.browser = null;
}
this.connected = false;
}
async closeBrowserContext(): Promise<void> {
try {
if (this.persistentContext) {
await this.persistentContext.close();
this.persistentContext = null;
this.page = null;
this.connected = false;
this.log('info', 'Persistent context closed');
return;
}
if (this.context) {
await this.context.close();
this.context = null;
this.page = null;
}
if (this.browser) {
await this.browser.close();
this.browser = null;
}
this.connected = false;
this.log('info', 'Browser closed successfully');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('warn', 'Error closing browser context', { error: message });
this.persistentContext = null;
this.context = null;
this.browser = null;
this.page = null;
this.connected = false;
}
}
}