feat(automation): add browser launch capability and default to dev mode

This commit is contained in:
2025-11-22 13:31:10 +01:00
parent bb17a4a085
commit 21d748b316
3 changed files with 97 additions and 35 deletions

View File

@@ -45,9 +45,11 @@ function createBrowserAutomationAdapter(mode: AutomationMode, logger: ILogger):
switch (mode) { switch (mode) {
case 'dev': case 'dev':
return new BrowserDevToolsAdapter({ return new BrowserDevToolsAdapter({
debuggingPort: config.devTools?.debuggingPort, debuggingPort: config.devTools?.debuggingPort ?? 9222,
browserWSEndpoint: config.devTools?.browserWSEndpoint, browserWSEndpoint: config.devTools?.browserWSEndpoint,
defaultTimeout: config.defaultTimeout, defaultTimeout: config.defaultTimeout,
launchBrowser: true,
startUrl: 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
}, logger.child({ adapter: 'BrowserDevTools' })); }, logger.child({ adapter: 'BrowserDevTools' }));
case 'production': case 'production':

View File

@@ -1,4 +1,4 @@
import puppeteer, { Browser, Page, CDPSession } from 'puppeteer-core'; import puppeteer, { Browser, Page } from 'puppeteer-core';
import { StepId } from '../../../packages/domain/value-objects/StepId'; import { StepId } from '../../../packages/domain/value-objects/StepId';
import { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation'; import { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation';
import { import {
@@ -27,6 +27,14 @@ export interface DevToolsConfig {
typingDelay?: number; typingDelay?: number;
/** Whether to wait for network idle after navigation (default: true) */ /** Whether to wait for network idle after navigation (default: true) */
waitForNetworkIdle?: boolean; waitForNetworkIdle?: boolean;
/** If true, launch a new browser instead of connecting to existing one */
launchBrowser?: boolean;
/** Path to Chrome executable (optional, puppeteer will try to find it) */
executablePath?: string;
/** Run browser in headless mode (default: false for visibility) */
headless?: boolean;
/** URL to navigate to after launching browser */
startUrl?: string;
} }
/** /**
@@ -52,10 +60,25 @@ export interface DevToolsConfig {
* await adapter.connect(); * await adapter.connect();
* ``` * ```
*/ */
/**
* Internal type with all config fields required (with defaults applied)
*/
type ResolvedDevToolsConfig = {
browserWSEndpoint: string;
debuggingPort: number;
defaultTimeout: number;
typingDelay: number;
waitForNetworkIdle: boolean;
launchBrowser: boolean;
executablePath: string;
headless: boolean;
startUrl: string;
};
export class BrowserDevToolsAdapter implements IBrowserAutomation { export class BrowserDevToolsAdapter implements IBrowserAutomation {
private browser: Browser | null = null; private browser: Browser | null = null;
private page: Page | null = null; private page: Page | null = null;
private config: Required<DevToolsConfig>; private config: ResolvedDevToolsConfig;
private connected: boolean = false; private connected: boolean = false;
private logger: ILogger; private logger: ILogger;
@@ -66,13 +89,18 @@ export class BrowserDevToolsAdapter implements IBrowserAutomation {
defaultTimeout: config.defaultTimeout ?? 30000, defaultTimeout: config.defaultTimeout ?? 30000,
typingDelay: config.typingDelay ?? 50, typingDelay: config.typingDelay ?? 50,
waitForNetworkIdle: config.waitForNetworkIdle ?? true, waitForNetworkIdle: config.waitForNetworkIdle ?? true,
launchBrowser: config.launchBrowser ?? false,
executablePath: config.executablePath ?? '',
headless: config.headless ?? false,
startUrl: config.startUrl ?? '',
}; };
this.logger = logger ?? new NoOpLogAdapter(); this.logger = logger ?? new NoOpLogAdapter();
} }
/** /**
* Connect to an existing browser via Chrome DevTools Protocol. * Connect to an existing browser via Chrome DevTools Protocol,
* The browser must be started with --remote-debugging-port flag. * or launch a new browser if launchBrowser is enabled.
* For connect mode, the browser must be started with --remote-debugging-port flag.
*/ */
async connect(): Promise<void> { async connect(): Promise<void> {
if (this.connected) { if (this.connected) {
@@ -81,47 +109,79 @@ export class BrowserDevToolsAdapter implements IBrowserAutomation {
} }
const startTime = Date.now(); const startTime = Date.now();
this.logger.info('Connecting to browser via CDP', {
debuggingPort: this.config.debuggingPort,
hasWsEndpoint: !!this.config.browserWSEndpoint
});
try { try {
if (this.config.browserWSEndpoint) { if (this.config.launchBrowser) {
// Connect using explicit WebSocket endpoint // LAUNCH mode - start a new browser
this.logger.debug('Using explicit WebSocket endpoint'); this.logger.info('Launching new browser instance', {
this.browser = await puppeteer.connect({ headless: this.config.headless,
browserWSEndpoint: this.config.browserWSEndpoint, hasExecutablePath: !!this.config.executablePath,
startUrl: this.config.startUrl || '(none)'
}); });
} else {
// Connect using debugging port - need to fetch endpoint first
this.logger.debug('Fetching WebSocket endpoint from debugging port');
const response = await fetch(`http://127.0.0.1:${this.config.debuggingPort}/json/version`);
const data = await response.json();
const wsEndpoint = data.webSocketDebuggerUrl;
this.browser = await puppeteer.connect({
browserWSEndpoint: wsEndpoint,
});
}
// Find iRacing tab or use the first available tab const launchArgs = [
const pages = await this.browser.pages(); '--start-maximized',
this.page = await this.findIRacingPage(pages) || pages[0]; '--no-sandbox',
];
if (!this.page) {
throw new Error('No pages found in browser'); this.browser = await puppeteer.launch({
headless: this.config.headless,
executablePath: this.config.executablePath || undefined,
args: launchArgs,
});
const pages = await this.browser.pages();
this.page = pages[0] || await this.browser.newPage();
if (this.config.startUrl) {
this.logger.debug('Navigating to start URL', { url: this.config.startUrl });
await this.page.goto(this.config.startUrl, {
waitUntil: this.config.waitForNetworkIdle ? 'networkidle2' : 'domcontentloaded'
});
}
} else {
// CONNECT mode - connect to existing browser
this.logger.info('Connecting to browser via CDP', {
debuggingPort: this.config.debuggingPort,
hasWsEndpoint: !!this.config.browserWSEndpoint
});
if (this.config.browserWSEndpoint) {
// Connect using explicit WebSocket endpoint
this.logger.debug('Using explicit WebSocket endpoint');
this.browser = await puppeteer.connect({
browserWSEndpoint: this.config.browserWSEndpoint,
});
} else {
// Connect using debugging port - need to fetch endpoint first
this.logger.debug('Fetching WebSocket endpoint from debugging port');
const response = await fetch(`http://127.0.0.1:${this.config.debuggingPort}/json/version`);
const data = await response.json();
const wsEndpoint = data.webSocketDebuggerUrl;
this.browser = await puppeteer.connect({
browserWSEndpoint: wsEndpoint,
});
}
// Find iRacing tab or use the first available tab
const pages = await this.browser.pages();
this.page = await this.findIRacingPage(pages) || pages[0];
if (!this.page) {
throw new Error('No pages found in browser');
}
} }
// Set default timeout // Set default timeout
this.page.setDefaultTimeout(this.config.defaultTimeout); this.page!.setDefaultTimeout(this.config.defaultTimeout);
this.connected = true; this.connected = true;
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
this.logger.info('Connected to browser successfully', { this.logger.info('Connected to browser successfully', {
durationMs, durationMs,
pageUrl: this.page.url(), pageUrl: this.page!.url(),
totalPages: pages.length launchMode: this.config.launchBrowser
}); });
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);

View File

@@ -51,7 +51,7 @@ export interface AutomationEnvironmentConfig {
*/ */
export function loadAutomationConfig(): AutomationEnvironmentConfig { export function loadAutomationConfig(): AutomationEnvironmentConfig {
const modeEnv = process.env.AUTOMATION_MODE; const modeEnv = process.env.AUTOMATION_MODE;
const mode: AutomationMode = isValidAutomationMode(modeEnv) ? modeEnv : 'mock'; const mode: AutomationMode = isValidAutomationMode(modeEnv) ? modeEnv : 'dev';
return { return {
mode, mode,