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, 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): 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 { const result = await this.connect(forceHeaded); if (!result.success) { throw new Error(result.error || 'Failed to connect browser'); } } private async cleanupStaleLockFile(userDataDir: string): Promise { 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 { 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 { 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; } } }