import puppeteer, { Browser, Page } from 'puppeteer-core'; import { StepId } from '../../../packages/domain/value-objects/StepId'; import { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation'; import { NavigationResult, FormFillResult, ClickResult, WaitResult, ModalResult, AutomationResult, } from '../../../packages/application/ports/AutomationResults'; import { IRacingSelectorMap, getStepSelectors, getStepName, isModalStep } from './selectors/IRacingSelectorMap'; import type { ILogger, LogContext } from '../../../application/ports/ILogger'; import { NoOpLogAdapter } from '../logging/NoOpLogAdapter'; /** * Configuration for connecting to browser via Chrome DevTools Protocol */ export interface DevToolsConfig { /** WebSocket endpoint URL (e.g., ws://127.0.0.1:9222/devtools/browser/...) */ browserWSEndpoint?: string; /** Chrome debugging port (default: 9222) */ debuggingPort?: number; /** Default timeout for operations in milliseconds (default: 30000) */ defaultTimeout?: number; /** Human-like typing delay in milliseconds (default: 50) */ typingDelay?: number; /** Whether to wait for network idle after navigation (default: true) */ 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; } /** * BrowserDevToolsAdapter - Real browser automation using Puppeteer-core. * * This adapter connects to an existing browser session via Chrome DevTools Protocol (CDP) * and automates the iRacing hosted session creation workflow. * * Key features: * - Connects to existing browser (doesn't launch new one) * - Uses IRacingSelectorMap for element location * - Human-like typing delays for form filling * - Waits for network idle after navigation * - Disconnects without closing browser * * Usage: * 1. Start Chrome with remote debugging: * `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222` * 2. Navigate to iRacing and log in manually * 3. Create adapter and connect: * ``` * const adapter = new BrowserDevToolsAdapter({ debuggingPort: 9222 }); * 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 { private browser: Browser | null = null; private page: Page | null = null; private config: ResolvedDevToolsConfig; private connected: boolean = false; private logger: ILogger; constructor(config: DevToolsConfig = {}, logger?: ILogger) { this.config = { browserWSEndpoint: config.browserWSEndpoint ?? '', debuggingPort: config.debuggingPort ?? 9222, defaultTimeout: config.defaultTimeout ?? 30000, typingDelay: config.typingDelay ?? 50, waitForNetworkIdle: config.waitForNetworkIdle ?? true, launchBrowser: config.launchBrowser ?? false, executablePath: config.executablePath ?? '', headless: config.headless ?? false, startUrl: config.startUrl ?? '', }; this.logger = logger ?? new NoOpLogAdapter(); } /** * Connect to an existing browser via Chrome DevTools Protocol, * 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 { if (this.connected) { this.logger.debug('Already connected to browser'); return; } const startTime = Date.now(); try { if (this.config.launchBrowser) { // LAUNCH mode - start a new browser this.logger.info('Launching new browser instance', { headless: this.config.headless, hasExecutablePath: !!this.config.executablePath, startUrl: this.config.startUrl || '(none)' }); const launchArgs = [ '--start-maximized', '--no-sandbox', ]; const launchOptions: Parameters[0] = { headless: this.config.headless, args: launchArgs, }; // Use explicit executablePath if provided, otherwise use channel to auto-detect Chrome if (this.config.executablePath) { launchOptions.executablePath = this.config.executablePath; } else { launchOptions.channel = 'chrome'; } this.browser = await puppeteer.launch(launchOptions); 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 this.page!.setDefaultTimeout(this.config.defaultTimeout); this.connected = true; const durationMs = Date.now() - startTime; this.logger.info('Connected to browser successfully', { durationMs, pageUrl: this.page!.url(), launchMode: this.config.launchBrowser }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error('Failed to connect to browser', error instanceof Error ? error : new Error(errorMessage)); throw new Error(`Failed to connect to browser: ${errorMessage}`); } } /** * Disconnect from the browser without closing it. * The user can continue using the browser after disconnection. */ async disconnect(): Promise { this.logger.info('Disconnecting from browser'); if (this.browser) { // Disconnect without closing - user may still use the browser this.browser.disconnect(); this.browser = null; this.page = null; } this.connected = false; this.logger.debug('Browser disconnected'); } /** * Check if adapter is connected to browser. */ isConnected(): boolean { return this.connected && this.browser !== null && this.page !== null; } /** * Navigate to a URL and wait for the page to load. */ async navigateToPage(url: string): Promise { this.ensureConnected(); const startTime = Date.now(); try { const waitUntil = this.config.waitForNetworkIdle ? 'networkidle2' : 'domcontentloaded'; await this.page!.goto(url, { waitUntil }); const loadTime = Date.now() - startTime; return { success: true, url, loadTime, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, url, loadTime: Date.now() - startTime, error: `Navigation failed: ${errorMessage}`, }; } } /** * Fill a form field with human-like typing delay. * * @param fieldName - Field identifier (will be looked up in selector map or used directly) * @param value - Value to type into the field */ async fillFormField(fieldName: string, value: string): Promise { this.ensureConnected(); try { // Try to find the element const element = await this.page!.$(fieldName); if (!element) { return { success: false, fieldName, valueSet: '', error: `Field not found: ${fieldName}`, }; } // Clear existing value and type new value with human-like delay await element.click({ clickCount: 3 }); // Select all existing text await element.type(value, { delay: this.config.typingDelay }); return { success: true, fieldName, valueSet: value, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, fieldName, valueSet: '', error: `Failed to fill field: ${errorMessage}`, }; } } /** * Click an element on the page. */ async clickElement(selector: string): Promise { this.ensureConnected(); try { // Wait for element to be visible and clickable await this.page!.waitForSelector(selector, { visible: true, timeout: this.config.defaultTimeout }); await this.page!.click(selector); return { success: true, target: selector, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, target: selector, error: `Click failed: ${errorMessage}`, }; } } /** * Wait for an element to appear on the page. */ async waitForElement(selector: string, maxWaitMs: number = 5000): Promise { this.ensureConnected(); const startTime = Date.now(); try { await this.page!.waitForSelector(selector, { timeout: maxWaitMs, visible: true }); return { success: true, target: selector, waitedMs: Date.now() - startTime, found: true, }; } catch (error) { return { success: false, target: selector, waitedMs: Date.now() - startTime, found: false, error: `Element not found within ${maxWaitMs}ms`, }; } } /** * Handle modal operations for specific workflow steps. * Modal steps are: 6 (SET_ADMINS), 9 (ADD_CAR), 12 (ADD_TRACK) */ async handleModal(stepId: StepId, action: string): Promise { this.ensureConnected(); if (!stepId.isModalStep()) { return { success: false, stepId: stepId.value, action, error: `Step ${stepId.value} (${getStepName(stepId.value)}) is not a modal step`, }; } try { const stepSelectors = getStepSelectors(stepId.value); if (!stepSelectors?.modal) { return { success: false, stepId: stepId.value, action, error: `No modal selectors defined for step ${stepId.value}`, }; } const modalSelectors = stepSelectors.modal; switch (action) { case 'open': // Wait for and verify modal is open await this.page!.waitForSelector(modalSelectors.container, { visible: true, timeout: this.config.defaultTimeout, }); break; case 'close': // Click close button await this.page!.click(modalSelectors.closeButton); // Wait for modal to disappear await this.page!.waitForSelector(modalSelectors.container, { hidden: true, timeout: this.config.defaultTimeout, }); break; case 'search': // Focus search input if available if (modalSelectors.searchInput) { await this.page!.click(modalSelectors.searchInput); } break; case 'select': // Click select/confirm button if (modalSelectors.selectButton) { await this.page!.click(modalSelectors.selectButton); } break; default: return { success: false, stepId: stepId.value, action, error: `Unknown modal action: ${action}`, }; } return { success: true, stepId: stepId.value, action, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, stepId: stepId.value, action, error: `Modal operation failed: ${errorMessage}`, }; } } // ============== Helper Methods ============== /** * Find the iRacing page among open browser tabs. */ private async findIRacingPage(pages: Page[]): Promise { for (const page of pages) { const url = page.url(); if (url.includes('iracing.com') || url.includes('members-ng.iracing.com')) { return page; } } return null; } /** * Ensure adapter is connected before operations. */ private ensureConnected(): void { if (!this.isConnected()) { throw new Error('Not connected to browser. Call connect() first.'); } } // ============== Extended Methods for Workflow Automation ============== /** * Navigate to a specific step in the wizard using sidebar navigation. */ async navigateToStep(stepId: StepId): Promise { this.ensureConnected(); const startTime = Date.now(); const stepSelectors = getStepSelectors(stepId.value); if (!stepSelectors?.sidebarLink) { return { success: false, url: '', loadTime: 0, error: `No sidebar link defined for step ${stepId.value} (${getStepName(stepId.value)})`, }; } try { await this.page!.click(stepSelectors.sidebarLink); // Wait for step container to be visible if (stepSelectors.container) { await this.page!.waitForSelector(stepSelectors.container, { visible: true }); } return { success: true, url: this.page!.url(), loadTime: Date.now() - startTime, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, url: this.page!.url(), loadTime: Date.now() - startTime, error: `Failed to navigate to step: ${errorMessage}`, }; } } /** * Get the current page URL. */ getCurrentUrl(): string { if (!this.page) { return ''; } return this.page.url(); } /** * Take a screenshot of the current page (useful for debugging). */ async takeScreenshot(path: string): Promise { this.ensureConnected(); await this.page!.screenshot({ path, fullPage: true }); } /** * Get the current page content (useful for debugging). */ async getPageContent(): Promise { this.ensureConnected(); return await this.page!.content(); } /** * Wait for network to be idle (no pending requests). */ async waitForNetworkIdle(timeout: number = 5000): Promise { this.ensureConnected(); await this.page!.waitForNetworkIdle({ timeout }); } /** * Execute JavaScript in the page context. */ async evaluate(fn: () => T): Promise { this.ensureConnected(); return await this.page!.evaluate(fn); } // ============== Step Execution ============== /** * Execute a complete workflow step with all required browser operations. * Uses IRacingSelectorMap to locate elements and performs appropriate actions. * * Step workflow: * 1. LOGIN - Skip (user pre-authenticated) * 2. HOSTED_RACING - Navigate to hosted racing page * 3. CREATE_RACE - Click create race button * 4. RACE_INFORMATION - Fill session name, password, description * 5. SERVER_DETAILS - Select server region, launch time * 6. SET_ADMINS - Add admins (modal step) * 7. TIME_LIMITS - Set practice/qualify/race times * 8. SET_CARS - Configure car selection * 9. ADD_CAR - Add cars (modal step) * 10. SET_CAR_CLASSES - Configure car classes * 11. SET_TRACK - Select track * 12. ADD_TRACK - Add track (modal step) * 13. TRACK_OPTIONS - Track configuration * 14. TIME_OF_DAY - Set time of day * 15. WEATHER - Weather settings * 16. RACE_OPTIONS - Race rules and options * 17. TEAM_DRIVING - Team settings * 18. TRACK_CONDITIONS - Final review (SAFETY STOP - no checkout) * * @param stepId - The step to execute (1-18) * @param config - Session configuration with form field values * @returns AutomationResult with success/failure and metadata */ async executeStep(stepId: StepId, config: Record): Promise { this.ensureConnected(); const stepNumber = stepId.value; const stepSelectors = getStepSelectors(stepNumber); const stepName = getStepName(stepNumber); const startTime = Date.now(); this.logger.info('Executing step', { stepId: stepNumber, stepName }); try { switch (stepNumber) { case 1: // LOGIN - Skip, user already authenticated return { success: true, metadata: { skipped: true, reason: 'User pre-authenticated', step: stepName } }; case 2: // HOSTED_RACING - Navigate to hosted racing page return await this.executeHostedRacingStep(); case 3: // CREATE_RACE - Click create race button return await this.executeCreateRaceStep(stepSelectors); case 4: // RACE_INFORMATION - Fill session details return await this.executeRaceInformationStep(stepSelectors, config); case 5: // SERVER_DETAILS - Configure server settings return await this.executeServerDetailsStep(stepSelectors, config); case 6: // SET_ADMINS - Add admins (modal step) return await this.executeSetAdminsStep(stepSelectors, config); case 7: // TIME_LIMITS - Configure time limits return await this.executeTimeLimitsStep(stepSelectors, config); case 8: // SET_CARS - Configure car selection return await this.executeSetCarsStep(stepSelectors); case 9: // ADD_CAR - Add cars (modal step) return await this.executeAddCarStep(stepSelectors, config); case 10: // SET_CAR_CLASSES - Configure car classes return await this.executeSetCarClassesStep(stepSelectors, config); case 11: // SET_TRACK - Select track return await this.executeSetTrackStep(stepSelectors); case 12: // ADD_TRACK - Add track (modal step) return await this.executeAddTrackStep(stepSelectors, config); case 13: // TRACK_OPTIONS - Track configuration return await this.executeTrackOptionsStep(stepSelectors, config); case 14: // TIME_OF_DAY - Set time of day return await this.executeTimeOfDayStep(stepSelectors, config); case 15: // WEATHER - Weather settings return await this.executeWeatherStep(stepSelectors, config); case 16: // RACE_OPTIONS - Race rules and options return await this.executeRaceOptionsStep(stepSelectors, config); case 17: // TEAM_DRIVING - Team settings return await this.executeTeamDrivingStep(stepSelectors, config); case 18: // TRACK_CONDITIONS - Final review (SAFETY STOP) return await this.executeTrackConditionsStep(stepSelectors, config); default: this.logger.warn('Unknown step requested', { stepId: stepNumber }); return { success: false, error: `Unknown step: ${stepNumber}`, metadata: { step: stepName } }; } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const durationMs = Date.now() - startTime; this.logger.error('Step execution failed', error instanceof Error ? error : new Error(errorMessage), { stepId: stepNumber, stepName, durationMs }); return { success: false, error: errorMessage, metadata: { step: stepName } }; } } // ============== Individual Step Implementations ============== private async executeHostedRacingStep(): Promise { const navResult = await this.navigateToPage(IRacingSelectorMap.urls.hostedRacing); if (!navResult.success) { return { success: false, error: navResult.error, metadata: { step: 'HOSTED_RACING' } }; } // Wait for page to be ready const stepSelectors = getStepSelectors(2); if (stepSelectors?.container) { await this.waitForElement(stepSelectors.container, this.config.defaultTimeout); } return { success: true, metadata: { step: 'HOSTED_RACING', url: IRacingSelectorMap.urls.hostedRacing } }; } private async executeCreateRaceStep(stepSelectors: ReturnType): Promise { if (!stepSelectors?.buttons?.createRace) { return { success: false, error: 'Create race button selector not defined', metadata: { step: 'CREATE_RACE' } }; } const clickResult = await this.clickElement(stepSelectors.buttons.createRace); if (!clickResult.success) { return { success: false, error: clickResult.error, metadata: { step: 'CREATE_RACE' } }; } // Wait for wizard modal to appear const waitResult = await this.waitForElement(IRacingSelectorMap.common.wizardContainer, this.config.defaultTimeout); if (!waitResult.success) { return { success: false, error: 'Wizard did not open', metadata: { step: 'CREATE_RACE' } }; } return { success: true, metadata: { step: 'CREATE_RACE' } }; } private async executeRaceInformationStep( stepSelectors: ReturnType, config: Record ): Promise { // Fill session name if provided if (config.sessionName && stepSelectors?.fields?.sessionName) { const fillResult = await this.fillFormField(stepSelectors.fields.sessionName, config.sessionName as string); if (!fillResult.success) { return { success: false, error: fillResult.error, metadata: { step: 'RACE_INFORMATION', field: 'sessionName' } }; } } // Fill password if provided if (config.password && stepSelectors?.fields?.password) { const fillResult = await this.fillFormField(stepSelectors.fields.password, config.password as string); if (!fillResult.success) { return { success: false, error: fillResult.error, metadata: { step: 'RACE_INFORMATION', field: 'password' } }; } } // Fill description if provided if (config.description && stepSelectors?.fields?.description) { const fillResult = await this.fillFormField(stepSelectors.fields.description, config.description as string); if (!fillResult.success) { return { success: false, error: fillResult.error, metadata: { step: 'RACE_INFORMATION', field: 'description' } }; } } // Click next button to proceed if (stepSelectors?.buttons?.next) { const clickResult = await this.clickElement(stepSelectors.buttons.next); if (!clickResult.success) { return { success: false, error: clickResult.error, metadata: { step: 'RACE_INFORMATION', action: 'next' } }; } } return { success: true, metadata: { step: 'RACE_INFORMATION' } }; } private async executeServerDetailsStep( stepSelectors: ReturnType, config: Record ): Promise { // Server region selection would require dropdown interaction // For now, accept defaults unless specific configuration is provided // Click next button to proceed if (stepSelectors?.buttons?.next) { const clickResult = await this.clickElement(stepSelectors.buttons.next); if (!clickResult.success) { return { success: false, error: clickResult.error, metadata: { step: 'SERVER_DETAILS', action: 'next' } }; } } return { success: true, metadata: { step: 'SERVER_DETAILS' } }; } private async executeSetAdminsStep( stepSelectors: ReturnType, config: Record ): Promise { // Admin step is a modal step - check if we need to add admins const adminIds = config.adminIds as string[] | undefined; if (adminIds && adminIds.length > 0 && stepSelectors?.modal) { // Open admin modal if (stepSelectors.buttons?.addAdmin) { const clickResult = await this.clickElement(stepSelectors.buttons.addAdmin); if (!clickResult.success) { return { success: false, error: clickResult.error, metadata: { step: 'SET_ADMINS', action: 'openModal' } }; } // Wait for modal to appear await this.waitForElement(stepSelectors.modal.container, this.config.defaultTimeout); // Search and select admins would require more complex interaction // For now, close the modal if (stepSelectors.modal.closeButton) { await this.clickElement(stepSelectors.modal.closeButton); } } } // Click next button to proceed if (stepSelectors?.buttons?.next) { const clickResult = await this.clickElement(stepSelectors.buttons.next); if (!clickResult.success) { return { success: false, error: clickResult.error, metadata: { step: 'SET_ADMINS', action: 'next' } }; } } return { success: true, metadata: { step: 'SET_ADMINS', isModalStep: true } }; } private async executeTimeLimitsStep( stepSelectors: ReturnType, config: Record ): Promise { // Fill practice length if provided if (config.practiceLength && stepSelectors?.fields?.practiceLength) { await this.fillFormField(stepSelectors.fields.practiceLength, String(config.practiceLength)); } // Fill qualify length if provided if (config.qualifyingLength && stepSelectors?.fields?.qualifyLength) { await this.fillFormField(stepSelectors.fields.qualifyLength, String(config.qualifyingLength)); } // Fill race length if provided if (config.raceLength && stepSelectors?.fields?.raceLength) { await this.fillFormField(stepSelectors.fields.raceLength, String(config.raceLength)); } // Fill warmup length if provided if (config.warmupLength && stepSelectors?.fields?.warmupLength) { await this.fillFormField(stepSelectors.fields.warmupLength, String(config.warmupLength)); } // Click next button to proceed if (stepSelectors?.buttons?.next) { const clickResult = await this.clickElement(stepSelectors.buttons.next); if (!clickResult.success) { return { success: false, error: clickResult.error, metadata: { step: 'TIME_LIMITS', action: 'next' } }; } } return { success: true, metadata: { step: 'TIME_LIMITS' } }; } private async executeSetCarsStep(stepSelectors: ReturnType): Promise { // This step shows the car selection overview // Actual car addition happens in step 9 (ADD_CAR modal) // Click next button to proceed to track selection if (stepSelectors?.buttons?.next) { const clickResult = await this.clickElement(stepSelectors.buttons.next); if (!clickResult.success) { return { success: false, error: clickResult.error, metadata: { step: 'SET_CARS', action: 'next' } }; } } return { success: true, metadata: { step: 'SET_CARS' } }; } private async executeAddCarStep( stepSelectors: ReturnType, config: Record ): Promise { // Add car is a modal step const carIds = config.carIds as string[] | undefined; if (carIds && carIds.length > 0 && stepSelectors?.modal) { // Click add car button to open modal const step8Selectors = getStepSelectors(8); if (step8Selectors?.buttons?.addCar) { const clickResult = await this.clickElement(step8Selectors.buttons.addCar); if (!clickResult.success) { return { success: false, error: clickResult.error, metadata: { step: 'ADD_CAR', action: 'openModal' } }; } // Wait for modal to appear await this.waitForElement(stepSelectors.modal.container, this.config.defaultTimeout); // Search for car would require typing in search field // For each car, we would search and select // For now, this is a placeholder for more complex car selection logic // Close modal after selection (or if no action needed) if (stepSelectors.modal.closeButton) { await this.clickElement(stepSelectors.modal.closeButton); } } } return { success: true, metadata: { step: 'ADD_CAR', isModalStep: true, carCount: carIds?.length ?? 0 } }; } private async executeSetCarClassesStep( stepSelectors: ReturnType, config: Record ): Promise { // Car classes configuration - usually auto-configured based on selected cars // Click next button to proceed if (stepSelectors?.buttons?.next) { const clickResult = await this.clickElement(stepSelectors.buttons.next); if (!clickResult.success) { return { success: false, error: clickResult.error, metadata: { step: 'SET_CAR_CLASSES', action: 'next' } }; } } return { success: true, metadata: { step: 'SET_CAR_CLASSES' } }; } private async executeSetTrackStep(stepSelectors: ReturnType): Promise { // This step shows the track selection overview // Actual track selection happens in step 12 (ADD_TRACK modal) // Click next button to proceed if (stepSelectors?.buttons?.next) { const clickResult = await this.clickElement(stepSelectors.buttons.next); if (!clickResult.success) { return { success: false, error: clickResult.error, metadata: { step: 'SET_TRACK', action: 'next' } }; } } return { success: true, metadata: { step: 'SET_TRACK' } }; } private async executeAddTrackStep( stepSelectors: ReturnType, config: Record ): Promise { // Add track is a modal step const trackId = config.trackId as string | undefined; if (trackId && stepSelectors?.modal) { // Click add track button to open modal const step11Selectors = getStepSelectors(11); if (step11Selectors?.buttons?.addTrack) { const clickResult = await this.clickElement(step11Selectors.buttons.addTrack); if (!clickResult.success) { return { success: false, error: clickResult.error, metadata: { step: 'ADD_TRACK', action: 'openModal' } }; } // Wait for modal to appear await this.waitForElement(stepSelectors.modal.container, this.config.defaultTimeout); // Search for track would require typing in search field // For now, this is a placeholder for more complex track selection logic // Close modal after selection (or if no action needed) if (stepSelectors.modal.closeButton) { await this.clickElement(stepSelectors.modal.closeButton); } } } return { success: true, metadata: { step: 'ADD_TRACK', isModalStep: true, trackId } }; } private async executeTrackOptionsStep( stepSelectors: ReturnType, config: Record ): Promise { // Track options like configuration, pit stalls etc. // Accept defaults for now // Click next button to proceed if (stepSelectors?.buttons?.next) { const clickResult = await this.clickElement(stepSelectors.buttons.next); if (!clickResult.success) { return { success: false, error: clickResult.error, metadata: { step: 'TRACK_OPTIONS', action: 'next' } }; } } return { success: true, metadata: { step: 'TRACK_OPTIONS' } }; } private async executeTimeOfDayStep( stepSelectors: ReturnType, config: Record ): Promise { // Time of day configuration // Accept defaults for now - time sliders are complex to interact with // Click next button to proceed if (stepSelectors?.buttons?.next) { const clickResult = await this.clickElement(stepSelectors.buttons.next); if (!clickResult.success) { return { success: false, error: clickResult.error, metadata: { step: 'TIME_OF_DAY', action: 'next' } }; } } return { success: true, metadata: { step: 'TIME_OF_DAY' } }; } private async executeWeatherStep( stepSelectors: ReturnType, config: Record ): Promise { // Weather configuration // Accept defaults for now // Click next button to proceed if (stepSelectors?.buttons?.next) { const clickResult = await this.clickElement(stepSelectors.buttons.next); if (!clickResult.success) { return { success: false, error: clickResult.error, metadata: { step: 'WEATHER', action: 'next' } }; } } return { success: true, metadata: { step: 'WEATHER' } }; } private async executeRaceOptionsStep( stepSelectors: ReturnType, config: Record ): Promise { // Race options like max drivers, hardcore incidents, etc. // Fill max drivers if provided if (config.maxDrivers && stepSelectors?.fields?.maxDrivers) { await this.fillFormField(stepSelectors.fields.maxDrivers, String(config.maxDrivers)); } // Click next button to proceed if (stepSelectors?.buttons?.next) { const clickResult = await this.clickElement(stepSelectors.buttons.next); if (!clickResult.success) { return { success: false, error: clickResult.error, metadata: { step: 'RACE_OPTIONS', action: 'next' } }; } } return { success: true, metadata: { step: 'RACE_OPTIONS' } }; } private async executeTeamDrivingStep( stepSelectors: ReturnType, config: Record ): Promise { // Team driving configuration // Accept defaults for now (usually disabled) // Click next button to proceed if (stepSelectors?.buttons?.next) { const clickResult = await this.clickElement(stepSelectors.buttons.next); if (!clickResult.success) { return { success: false, error: clickResult.error, metadata: { step: 'TEAM_DRIVING', action: 'next' } }; } } return { success: true, metadata: { step: 'TEAM_DRIVING' } }; } private async executeTrackConditionsStep( stepSelectors: ReturnType, config: Record ): Promise { // FINAL STEP - SAFETY STOP // We fill track conditions but DO NOT click checkout button // Track state selection would require dropdown interaction // For now, accept defaults return { success: true, metadata: { step: 'TRACK_CONDITIONS', safetyStop: true, message: 'Automation stopped at final step. User must review configuration and click checkout manually.', checkoutButtonSelector: IRacingSelectorMap.common.checkoutButton } }; } }