268 lines
8.5 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
} |