import { chromium } from 'playwright-extra'; import StealthPlugin from 'puppeteer-extra-plugin-stealth'; import { Browser, Page, BrowserContext } from 'playwright'; import * as fs from 'fs'; import * as path from 'path'; // Add stealth plugin to avoid bot detection chromium.use(StealthPlugin()); import { StepId } from '../../../domain/value-objects/StepId'; import { AuthenticationState } from '../../../domain/value-objects/AuthenticationState'; import { BrowserAuthenticationState } from '../../../domain/value-objects/BrowserAuthenticationState'; import { CheckoutPrice } from '../../../domain/value-objects/CheckoutPrice'; import { CheckoutState } from '../../../domain/value-objects/CheckoutState'; import { CheckoutConfirmation } from '../../../domain/value-objects/CheckoutConfirmation'; import type { IBrowserAutomation, NavigationResult, FormFillResult, ClickResult, WaitResult, ModalResult, AutomationResult, } from '../../../application/ports/IScreenAutomation'; import type { IAuthenticationService } from '../../../application/ports/IAuthenticationService'; import type { ILogger } from '../../../application/ports/ILogger'; import { Result } from '../../../shared/result/Result'; import { IRACING_SELECTORS, IRACING_URLS, IRACING_TIMEOUTS, ALL_BLOCKED_SELECTORS, BLOCKED_KEYWORDS } from './IRacingSelectors'; import { SessionCookieStore } from './SessionCookieStore'; import { AuthenticationGuard } from './AuthenticationGuard'; import { BrowserModeConfigLoader, BrowserMode } from '../../config/BrowserModeConfig'; import { getAutomationMode } from '../../config/AutomationConfig'; import { PageStateValidator, PageStateValidation, PageStateValidationResult } from '../../../domain/services/PageStateValidator'; export type AutomationAdapterMode = 'mock' | 'real'; /** * Personality messages for the automation overlay. * These add a fun, racing-themed personality to the bot. */ const OVERLAY_PERSONALITY_MESSAGES = [ "🏎️ Warming up the tires...", "🔧 Fine-tuning the setup...", "🏁 Getting ready for the green flag...", "⚡ Optimizing lap times...", "🎯 Locking in your preferences...", "🌟 Making racing dreams come true...", "🚀 Preparing for launch...", "🏆 Setting you up for victory...", "🎮 Configuring the perfect session...", "⏱️ Every millisecond counts...", "🛞 Checking tire pressures...", "📡 Syncing with race control...", "🔥 Engines are warming up...", "💨 Almost race time!", "🗺️ Plotting the racing line...", ]; /** * Step-specific messages for the overlay. * Maps step numbers to friendly descriptions. */ const OVERLAY_STEP_MESSAGES: Record = { 1: "🔐 Checking your credentials...", 2: "🏁 Creating your race session...", 3: "📝 Setting up race information...", 4: "🖥️ Configuring server details...", 5: "👥 Managing admin access...", 6: "➕ Adding admin privileges...", 7: "⏰ Setting time limits...", 8: "🚗 Selecting your cars...", 9: "🏎️ Adding your car to the grid...", 10: "🎨 Configuring car classes...", 11: "🗺️ Choosing your track...", 12: "🏟️ Adding track to session...", 13: "⚙️ Setting track options...", 14: "🌅 Configuring time of day...", 15: "🌤️ Setting weather conditions...", 16: "🌦️ Configuring track conditions...", 17: "✅ Done! Review your settings and click 'Host Race' to create your session!", }; /** * CSS styles for the automation overlay. * Styled to match iRacing's dark theme with racing accents. * Colors extracted from iRacing HTML fixtures: * - Primary dark: #12121B (iRacing brand dark) * - Gray background: #1a1a24 (gray-800 equivalent) * - Text light: rgba(255, 255, 255, 0.92) (whiteAlpha-900) * - Accent: #c8102e (iRacing red) * - Button active: #4e4e57 * - Border: rgba(183, 183, 187, 0.3) */ const OVERLAY_CSS = ` @keyframes gridpilot-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.85; transform: scale(1.03); } } @keyframes gridpilot-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes gridpilot-slide-in { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes gridpilot-checkered { 0% { background-position: 0 0; } 100% { background-position: 20px 20px; } } @keyframes gridpilot-progress { 0% { background-position: 0% 50%; } 100% { background-position: 100% 50%; } } #gridpilot-overlay { position: fixed; bottom: 20px; right: 20px; width: 340px; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; z-index: 2147483647; animation: gridpilot-slide-in 0.4s ease-out; pointer-events: auto; } #gridpilot-overlay * { box-sizing: border-box; } .gridpilot-card { background: #12121B; border-radius: 4px; border: 1px solid rgba(183, 183, 187, 0.2); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); overflow: hidden; } .gridpilot-header { background: linear-gradient(90deg, #c8102e 0%, #a00d25 100%); padding: 10px 14px; display: flex; align-items: center; gap: 10px; position: relative; overflow: hidden; } .gridpilot-header::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(45deg, transparent 48%, rgba(255,255,255,0.05) 49%, rgba(255,255,255,0.05) 51%, transparent 52%), linear-gradient(-45deg, transparent 48%, rgba(255,255,255,0.05) 49%, rgba(255,255,255,0.05) 51%, transparent 52%); background-size: 8px 8px; animation: gridpilot-checkered 1.5s linear infinite; opacity: 0.5; } .gridpilot-logo { font-size: 22px; animation: gridpilot-pulse 2s ease-in-out infinite; position: relative; z-index: 1; } .gridpilot-title { color: #ffffff; font-size: 13px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; position: relative; z-index: 1; text-shadow: 0 1px 2px rgba(0,0,0,0.3); flex: 1; } .gridpilot-btn { background: rgba(255, 255, 255, 0.15); color: #ffffff; border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 3px; padding: 4px 10px; font-size: 11px; font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase; cursor: pointer; position: relative; z-index: 1; transition: all 0.15s ease; } .gridpilot-btn:hover { background: rgba(255, 255, 255, 0.25); border-color: rgba(255, 255, 255, 0.5); } .gridpilot-btn:active { background: rgba(255, 255, 255, 0.35); transform: scale(0.97); } .gridpilot-btn.paused { background: #4e4e57; border-color: #ffffff; color: #ffffff; animation: gridpilot-pulse 1s ease-in-out infinite; } .gridpilot-close-btn { background: rgba(200, 16, 46, 0.6); border-color: rgba(200, 16, 46, 0.8); } .gridpilot-close-btn:hover { background: rgba(200, 16, 46, 0.8); border-color: #c8102e; } .gridpilot-close-btn:active { background: #c8102e; } .gridpilot-header-buttons { display: flex; gap: 6px; position: relative; z-index: 1; } .gridpilot-body { padding: 14px; background: #1a1a24; } .gridpilot-status { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } .gridpilot-spinner { width: 22px; height: 22px; border: 2px solid rgba(200, 16, 46, 0.3); border-top-color: #c8102e; border-radius: 50%; animation: gridpilot-spin 0.8s linear infinite; flex-shrink: 0; } .gridpilot-spinner.paused { animation-play-state: paused; border-top-color: #777880; border-color: rgba(119, 120, 128, 0.3); } .gridpilot-action-text { color: rgba(255, 255, 255, 0.92); font-size: 14px; font-weight: 500; line-height: 1.4; } .gridpilot-progress-container { margin-bottom: 12px; } .gridpilot-progress-bar { height: 4px; background: rgba(78, 78, 87, 0.5); border-radius: 2px; overflow: hidden; } .gridpilot-progress-fill { height: 100%; background: linear-gradient(90deg, #c8102e, #e8304a, #c8102e); background-size: 200% 100%; animation: gridpilot-progress 2s linear infinite; border-radius: 2px; transition: width 0.4s ease-out; } .gridpilot-progress-fill.paused { animation-play-state: paused; background: #777880; } .gridpilot-step-info { display: flex; justify-content: space-between; align-items: center; margin-top: 6px; } .gridpilot-step-text { color: rgba(255, 255, 255, 0.6); font-size: 11px; } .gridpilot-step-count { color: #c8102e; font-size: 11px; font-weight: 600; } .gridpilot-personality { color: rgba(255, 255, 255, 0.5); font-size: 11px; font-style: italic; text-align: center; padding-top: 10px; border-top: 1px solid rgba(183, 183, 187, 0.15); } .gridpilot-footer { background: #12121B; padding: 8px 14px; display: flex; align-items: center; justify-content: center; gap: 6px; border-top: 1px solid rgba(183, 183, 187, 0.1); } .gridpilot-footer-text { color: rgba(255, 255, 255, 0.4); font-size: 10px; letter-spacing: 0.5px; } .gridpilot-footer-dot { width: 4px; height: 4px; background: #c8102e; border-radius: 50%; animation: gridpilot-pulse 1.5s ease-in-out infinite; } .gridpilot-footer-dot.paused { background: #777880; animation: none; } `; /** * HTML template for the automation overlay. * Includes pause/resume button and close button for user control. */ const OVERLAY_HTML = `
GridPilot
Initializing...
Starting up... Step 0 of 17
🏁 Getting ready for the green flag...
`; export interface PlaywrightConfig { headless?: boolean; timeout?: number; baseUrl?: string; mode?: AutomationAdapterMode; /** Path to store persistent browser context (session data). Required for real mode. */ userDataDir?: string; } export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthenticationService { private browser: Browser | null = null; private persistentContext: BrowserContext | null = null; private context: BrowserContext | null = null; private page: Page | null = null; private config: Required; private connected = false; private isConnecting = false; private logger?: ILogger; private authState: AuthenticationState = AuthenticationState.UNKNOWN; private cookieStore: SessionCookieStore; private overlayInjected = false; private totalSteps = 17; private browserModeLoader: BrowserModeConfigLoader; private actualBrowserMode: BrowserMode; private browserModeSource: 'env' | 'file' | 'default'; /** Polling interval for pause check (ms) */ private static readonly PAUSE_CHECK_INTERVAL = 300; /** Checkout confirmation callback - called before clicking checkout button */ private checkoutConfirmationCallback?: (price: CheckoutPrice, state: CheckoutState) => Promise; /** Page state validator instance */ private pageStateValidator: PageStateValidator; constructor(config: PlaywrightConfig = {}, logger?: ILogger, browserModeLoader?: BrowserModeConfigLoader) { this.config = { headless: true, timeout: 10000, baseUrl: '', mode: 'mock', userDataDir: '', ...config, }; this.logger = logger; this.cookieStore = new SessionCookieStore(this.config.userDataDir, logger); this.pageStateValidator = new PageStateValidator(); // Initialize browser mode configuration (allow injection of loader for tests) const automationMode = getAutomationMode(); this.browserModeLoader = browserModeLoader ?? new BrowserModeConfigLoader(); const browserModeConfig = this.browserModeLoader.load(); this.actualBrowserMode = browserModeConfig.mode; this.browserModeSource = browserModeConfig.source as any; // Log browser mode decision this.log('info', 'Browser mode configured', { mode: this.actualBrowserMode, source: this.browserModeSource, automationMode, configHeadless: this.config.headless, }); } private isRealMode(): boolean { return this.config.mode === 'real'; } /** * Validate that the current page state matches expected conditions. * Uses the PageStateValidator domain service to check selector presence/absence. * * @param validation Expected page state configuration * @returns Result with validation outcome */ async validatePageState(validation: PageStateValidation): Promise> { if (!this.page) { return Result.err(new Error('Browser not connected')); } try { // Create a function that checks if selectors exist on the page const checkSelector = (selector: string): boolean => { // Synchronously check if selector exists (count > 0) // We'll need to make this sync-compatible, so we check in the validator call return false; // Placeholder - will be resolved in evaluate }; // Use page.evaluate to check all selectors at once in the browser context const selectorChecks = await this.page.evaluate( ({ requiredSelectors, forbiddenSelectors }) => { const results: Record = {}; // Check required selectors for (const selector of requiredSelectors) { try { results[selector] = document.querySelectorAll(selector).length > 0; } catch { results[selector] = false; } } // Check forbidden selectors for (const selector of forbiddenSelectors || []) { try { results[selector] = document.querySelectorAll(selector).length > 0; } catch { results[selector] = false; } } return results; }, { requiredSelectors: validation.requiredSelectors, forbiddenSelectors: validation.forbiddenSelectors || [] } ); // Create actualState function that uses the captured results const actualState = (selector: string): boolean => { return selectorChecks[selector] === true; }; // Validate using domain service return this.pageStateValidator.validateState(actualState, validation); } catch (error) { return Result.err( error instanceof Error ? error : new Error(`Page state validation failed: ${String(error)}`) ); } } /** Maximum number of "before" debug snapshots to keep */ private static readonly MAX_BEFORE_SNAPSHOTS = 5; private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record): void { if (this.logger) { this.logger[level](message, context); } } async connect(forceHeaded: boolean = false): Promise { // If already connected, return success if (this.connected && this.page) { this.log('debug', 'Already connected, reusing existing connection'); return { success: true }; } // If currently connecting, wait and retry 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(); // Update cached mode and source so other methods observe the latest config this.actualBrowserMode = currentConfig.mode; this.browserModeSource = currentConfig.source as any; const effectiveMode = forceHeaded ? 'headed' : currentConfig.mode; // Test hook: use injected testLauncher if present to avoid real Playwright launches const launcher = (PlaywrightAutomationAdapter as any).testLauncher ?? chromium; // Instrumentation: log what effective mode is being used for launch this.log('debug', 'Effective browser mode at connect', { effectiveMode, actualBrowserMode: this.actualBrowserMode, browserModeSource: this.browserModeSource, forced: forceHeaded, }); // Test-only console instrumentation (guarded to avoid noise in production) 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; // Include both loader.load() output and the adapter-reported source console.debug('[TEST-INSTRUMENT] PlaywrightAutomationAdapter.connect()', { effectiveMode, forceHeaded, loaderValue, browserModeSource: this.getBrowserModeSource ? this.getBrowserModeSource() : this.browserModeSource, }); } catch (e) { // Swallow any errors from test instrumentation } } // In real mode with userDataDir, use persistent context for session persistence if (this.isRealMode() && this.config.userDataDir) { this.log('info', 'Launching persistent browser context', { userDataDir: this.config.userDataDir, mode: effectiveMode, forced: forceHeaded }); // Ensure the directory exists if (!fs.existsSync(this.config.userDataDir)) { fs.mkdirSync(this.config.userDataDir, { recursive: true }); } // Clean up stale lock files before launching await this.cleanupStaleLockFile(this.config.userDataDir); this.persistentContext = await launcher.launchPersistentContext( this.config.userDataDir, { headless: effectiveMode === 'headless', // Stealth options to avoid bot detection args: [ '--disable-blink-features=AutomationControlled', '--disable-features=IsolateOrigins,site-per-process', ], ignoreDefaultArgs: ['--enable-automation'], // Mimic real browser viewport and user agent 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', } ); this.page = this.persistentContext.pages()[0] || await this.persistentContext.newPage(); this.page.setDefaultTimeout(this.config.timeout ?? 10000); this.connected = true; return { success: true }; } // Non-persistent mode (mock or no userDataDir) this.browser = await launcher.launch({ headless: effectiveMode === 'headless', // Stealth options to avoid bot detection args: [ '--disable-blink-features=AutomationControlled', '--disable-features=IsolateOrigins,site-per-process', ], ignoreDefaultArgs: ['--enable-automation'], }); this.context = await this.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; } } /** * Ensure browser context is ready for automation. * This is a wrapper around connect() that allows forcing headed mode. * * @param forceHeaded If true, override browser mode to 'headed' (visible browser) */ private async ensureBrowserContext(forceHeaded: boolean = false): Promise { const result = await this.connect(forceHeaded); if (!result.success) { throw new Error(result.error || 'Failed to connect browser'); } } /** * Clean up stale SingletonLock file if it exists and the owning process is not running. * On Unix systems, SingletonLock is a symbolic link pointing to a socket file. * If the browser crashed or was force quit, this file remains and blocks new launches. */ private async cleanupStaleLockFile(userDataDir: string): Promise { const singletonLockPath = path.join(userDataDir, 'SingletonLock'); try { // Check if lock file exists if (!fs.existsSync(singletonLockPath)) { return; // No lock file, we're good } this.log('info', 'Found existing SingletonLock, attempting cleanup', { path: singletonLockPath }); // Try to remove the lock file // On Unix, SingletonLock is typically a symlink, so unlink works for both files and symlinks fs.unlinkSync(singletonLockPath); this.log('info', 'Cleaned up stale SingletonLock file'); } catch (error) { // If we can't remove it, the browser might actually be running // Log warning but continue - Playwright will give us a proper error if it's actually locked 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; } isConnected(): boolean { return this.connected && this.page !== null; } async navigateToPage(url: string): Promise { if (!this.page) { return { success: false, error: 'Browser not connected' }; } try { const targetUrl = this.isRealMode() && !url.startsWith('http') ? IRACING_URLS.hostedSessions : url; const timeout = this.isRealMode() ? IRACING_TIMEOUTS.navigation : this.config.timeout; this.log('debug', 'Navigating to page', { url: targetUrl, mode: this.config.mode }); await this.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout }); // In mock mode, extract step number from URL and add data-step attribute to body // This is needed for getCurrentStep() to work in tests if (!this.isRealMode()) { const stepMatch = url.match(/step-(\d+)-/); if (stepMatch) { const stepNumber = parseInt(stepMatch[1], 10); await this.page.evaluate((step) => { document.body.setAttribute('data-step', String(step)); }, stepNumber); } } // Reset overlay state after navigation (page context changed) this.resetOverlayState(); return { success: true, url: targetUrl }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { success: false, error: message }; } } async fillFormField(fieldName: string, value: string): Promise { if (!this.page) { return { success: false, fieldName, value, error: 'Browser not connected' }; } try { const selector = this.getFieldSelector(fieldName); const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; this.log('debug', 'Filling form field', { fieldName, selector, mode: this.config.mode }); // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" await this.page.waitForSelector(selector, { state: 'attached', timeout }); await this.page.fill(selector, value); return { success: true, fieldName, value }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { success: false, fieldName, value, error: message }; } } private getFieldSelector(fieldName: string): string { const fieldMap: Record = { sessionName: `${IRACING_SELECTORS.steps.sessionName}, ${IRACING_SELECTORS.steps.sessionNameAlt}`, password: `${IRACING_SELECTORS.steps.password}, ${IRACING_SELECTORS.steps.passwordAlt}`, description: `${IRACING_SELECTORS.steps.description}, ${IRACING_SELECTORS.steps.descriptionAlt}`, adminSearch: IRACING_SELECTORS.steps.adminSearch, carSearch: IRACING_SELECTORS.steps.carSearch, trackSearch: IRACING_SELECTORS.steps.trackSearch, maxDrivers: IRACING_SELECTORS.steps.maxDrivers, }; return fieldMap[fieldName] || IRACING_SELECTORS.fields.textInput; } async clickElement(target: string): Promise { if (!this.page) { return { success: false, error: 'Browser not connected' }; } try { const selector = this.getActionSelector(target); const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; this.log('debug', 'Clicking element', { target, selector, mode: this.config.mode }); // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" await this.page.waitForSelector(selector, { state: 'attached', timeout }); await this.page.click(selector); return { success: true }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { success: false, error: message }; } } private getActionSelector(action: string): string { // If already a selector, return as-is if (action.startsWith('[') || action.startsWith('button') || action.startsWith('#')) { return action; } const actionMap: Record = { next: IRACING_SELECTORS.wizard.nextButton, back: IRACING_SELECTORS.wizard.backButton, confirm: IRACING_SELECTORS.wizard.confirmButton, cancel: IRACING_SELECTORS.wizard.cancelButton, create: IRACING_SELECTORS.hostedRacing.createRaceButton, close: IRACING_SELECTORS.wizard.closeButton, }; return actionMap[action] || `button:has-text("${action}")`; } async waitForElement(target: string, maxWaitMs?: number): Promise { if (!this.page) { return { success: false, error: 'Browser not connected' }; } const startTime = Date.now(); const defaultTimeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; try { let selector: string; if (target.startsWith('[') || target.startsWith('button') || target.startsWith('#')) { selector = target; } else { // Wait for modal/wizard elements instead of step containers selector = IRACING_SELECTORS.wizard.modal; } this.log('debug', 'Waiting for element', { target, selector, mode: this.config.mode }); // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" await this.page.waitForSelector(selector, { state: 'attached', timeout: maxWaitMs ?? defaultTimeout, }); return { success: true, waitTime: Date.now() - startTime }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { success: false, error: message, waitTime: Date.now() - startTime }; } } async handleModal(_stepId: StepId, action: string): Promise { if (!this.page) { return { success: false, error: 'Browser not connected' }; } try { const modalSelector = IRACING_SELECTORS.wizard.modal; const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; this.log('debug', 'Handling modal', { action, mode: this.config.mode }); // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" await this.page.waitForSelector(modalSelector, { state: 'attached', timeout }); let buttonSelector: string; if (action === 'confirm') { buttonSelector = IRACING_SELECTORS.wizard.confirmButton; } else if (action === 'cancel') { buttonSelector = IRACING_SELECTORS.wizard.cancelButton; } else { return { success: false, error: `Unknown modal action: ${action}` }; } await this.page.click(buttonSelector); return { success: true, action }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { success: false, error: message }; } } async executeStep(stepId: StepId, config: Record): Promise { if (!this.page) { return { success: false, error: 'Browser not connected' }; } const step = stepId.value; this.log('info', 'Executing step', { step, mode: this.config.mode }); try { // Check if paused before proceeding (real mode only) if (this.isRealMode()) { await this.waitIfPaused(); // Check if user requested browser close await this.checkAndHandleClose(); } // Wizard auto-skip detection and synchronization (real mode only) // Only check for auto-skip AFTER waiting for the step container // This ensures we don't prematurely skip steps in mock mode // The actual skip detection happens later in the switch statement for steps 8-10 // Inject and update overlay at the start of each step (real mode only) if (this.isRealMode()) { await this.updateOverlay(step); } // Save proactive debug dump BEFORE step execution // This ensures we have state captured even if Ctrl+C closes the browser const beforeDebugPaths = await this.saveProactiveDebugInfo(step); if (beforeDebugPaths.screenshotPath || beforeDebugPaths.htmlPath) { this.log('info', `Pre-step debug snapshot saved for step ${step}`, { screenshot: beforeDebugPaths.screenshotPath, html: beforeDebugPaths.htmlPath, }); } // Dismiss any modal popups that might be blocking interactions await this.dismissModals(); // Step 1: Login handling (real mode only) if (step === 1 && this.isRealMode()) { return this.handleLogin(); } // For real mode, we don't wait for step containers if (!this.isRealMode()) { await this.waitForStep(step); } switch (step) { case 1: // Step 1: Login handling (real mode only) - already handled above break; case 2: // Step 2: Click "Create a Race" button to navigate to step 3 await this.clickAction('create'); break; case 3: // Step 3: Race Information - fill session details and navigate to next step // In mock mode, we're already on the form page (navigated here from step 2) // In real mode, a modal appears asking "Last Settings" or "New Race" - click "New Race" if (this.isRealMode()) { await this.clickNewRaceInModal(); // Ensure Race Information panel is visible by clicking sidebar nav then waiting for fallback selectors const raceInfoFallback = '#set-session-information, .wizard-step[id*="session"], .wizard-step[id*="race-information"]'; try { try { await this.page!.click('[data-testid="wizard-nav-set-session-information"]'); this.log('debug','Clicked wizard nav for Race Information', { selector: '[data-testid="wizard-nav-set-session-information"]' }); } catch (e) { this.log('debug','Wizard nav for Race Information not present (continuing)', { error: String(e) }); } await this.page!.waitForSelector(raceInfoFallback, { state: 'attached', timeout: 5000 }); this.log('info','Race Information panel found', { selector: raceInfoFallback }); } catch (err) { this.log('warn','Race Information panel not found with fallback selector, dumping #create-race-wizard innerHTML', { selector: raceInfoFallback }); const inner = await this.page!.evaluate(() => document.querySelector('#create-race-wizard')?.innerHTML || ''); this.log('debug','create-race-wizard innerHTML (truncated)', { html: inner ? inner.substring(0,2000) : '' }); // Retry nav click once then wait longer before failing try { await this.page!.click('[data-testid="wizard-nav-set-session-information"]'); } catch {} await this.page!.waitForSelector(raceInfoFallback, { state: 'attached', timeout: 10000 }); } } // Fill form fields if provided if (config.sessionName) { await this.fillFieldWithFallback('sessionName', String(config.sessionName)); } if (config.password) { await this.fillFieldWithFallback('password', String(config.password)); } if (config.description) { await this.fillFieldWithFallback('description', String(config.description)); } // Click next to navigate to step 4 await this.clickNextButton('Server Details'); break; case 4: // Step 4: Server Details // In real mode, wait for the wizard step to be visible if (this.isRealMode()) { await this.waitForWizardStep('serverDetails'); // Check if wizard was dismissed after confirming step loaded await this.checkWizardDismissed(step); } if (config.region) { await this.selectDropdown('region', String(config.region)); } if (config.startNow !== undefined) { await this.setToggle('startNow', Boolean(config.startNow)); } await this.clickNextButton('Admins'); break; case 5: // Step 5: Set Admins (view admins list) if (this.isRealMode()) { await this.waitForWizardStep('admins'); // Check if wizard was dismissed after confirming step loaded await this.checkWizardDismissed(step); } await this.clickNextButton('Time Limit'); break; case 6: // Step 6: Set Admins (manage admin permissions) // This step displays the admin management page where users can add/remove admins if (this.isRealMode()) { await this.waitForWizardStep('admins'); // Check if wizard was dismissed after confirming step loaded await this.checkWizardDismissed(step); } await this.clickNextButton('Time Limit'); break; case 7: // Step 7: Time Limits if (this.isRealMode()) { await this.waitForWizardStep('timeLimit'); // Check if wizard was dismissed after confirming step loaded await this.checkWizardDismissed(step); } if (config.practice !== undefined) { await this.setSlider('practice', Number(config.practice)); } if (config.qualify !== undefined) { await this.setSlider('qualify', Number(config.qualify)); } if (config.race !== undefined) { await this.setSlider('race', Number(config.race)); } await this.clickNextButton('Cars'); break; case 8: // Step 8: Set Cars (view only - navigation deferred to Step 9) if (this.isRealMode()) { // Check for wizard auto-skip BEFORE trying to interact with the page const actualPage = await this.detectCurrentWizardPage(); const skipOffset = this.synchronizeStepCounter(step, actualPage); if (skipOffset > 0) { // Wizard skipped steps 8-10, we're already on step 11 (Track) this.log('info', `Step ${step} was auto-skipped by wizard`, { actualPage, skipOffset }); return { success: true }; } // Robust: try opening Cars via sidebar nav then wait for a set of fallback selectors. const carsFallbackSelector = '#set-cars, .wizard-step[id*="cars"], .cars-panel'; try { try { await this.page!.click('[data-testid="wizard-nav-set-cars"]'); this.log('debug', 'Clicked wizard nav for Cars', { selector: '[data-testid="wizard-nav-set-cars"]' }); } catch (e) { this.log('debug', 'Wizard nav for Cars not present (continuing)', { error: String(e) }); } try { await this.page!.waitForSelector(carsFallbackSelector, { state: 'attached', timeout: 5000 }); this.log('info', 'Cars panel found', { selector: carsFallbackSelector }); } catch (err) { this.log('warn', 'Cars panel not found with fallback selector, dumping #create-race-wizard innerHTML', { selector: carsFallbackSelector }); const inner = await this.page!.evaluate(() => document.querySelector('#create-race-wizard')?.innerHTML || ''); this.log('debug', 'create-race-wizard innerHTML (truncated)', { html: inner ? inner.substring(0, 2000) : '' }); // Retry nav click once then wait longer before failing try { await this.page!.click('[data-testid="wizard-nav-set-cars"]'); } catch {} await this.page!.waitForSelector(carsFallbackSelector, { state: 'attached', timeout: 10000 }); } } catch (e) { this.log('error', 'Failed waiting for Cars panel', { error: String(e), selector: carsFallbackSelector }); } await this.checkWizardDismissed(step); } // CRITICAL: Validate we're on the correct page before proceeding (both modes) this.log('info', 'Step 8: Validating page state before proceeding'); const step8Validation = await this.validatePageState({ expectedStep: 'cars', requiredSelectors: this.isRealMode() ? [IRACING_SELECTORS.steps.addCarButton] : ['#set-cars, .wizard-step[id*="cars"], .cars-panel'], // Mock mode: check for Cars container (fallbacks) forbiddenSelectors: ['#set-track'] }); if (step8Validation.isErr()) { // Exception during validation const errorMsg = `Step 8 validation error: ${step8Validation.error.message}`; this.log('error', errorMsg); throw new Error(errorMsg); } const step8ValidationResult = step8Validation.unwrap(); this.log('info', 'Step 8 validation result', { isValid: step8ValidationResult.isValid, message: step8ValidationResult.message, missingSelectors: step8ValidationResult.missingSelectors, unexpectedSelectors: step8ValidationResult.unexpectedSelectors }); if (!step8ValidationResult.isValid) { // Validation failed - wrong page const errorMsg = `Step 8 FAILED validation: ${step8ValidationResult.message}`; this.log('error', errorMsg, { missing: step8ValidationResult.missingSelectors, unexpected: step8ValidationResult.unexpectedSelectors }); throw new Error(errorMsg); } this.log('info', 'Step 8 validation passed - on Cars page'); // DO NOT click next - Step 9 will handle navigation break; case 9: // Step 9: Add a Car (modal) + Navigate to Track // CRITICAL: Validate we're still on Cars page before any actions (both modes) this.log('info', 'Step 9: Validating we are still on Cars page'); if (this.isRealMode()) { // Check for wizard auto-skip BEFORE trying to interact with the page const actualPage = await this.detectCurrentWizardPage(); const skipOffset = this.synchronizeStepCounter(step, actualPage); if (skipOffset > 0) { // Wizard skipped steps 8-10, we're already on step 11 (Track) this.log('info', `Step ${step} was auto-skipped by wizard`, { actualPage, skipOffset }); return { success: true }; } // Real mode: check wizard footer const wizardFooter = await this.page!.locator('.wizard-footer').innerText().catch(() => ''); this.log('info', 'Step 9: Current wizard footer', { footer: wizardFooter }); // Check if we're on Track page (Step 11) instead of Cars page const onTrackPage = wizardFooter.includes('Track Options') || await this.page!.locator('#set-track').isVisible().catch(() => false); if (onTrackPage) { const errorMsg = `FATAL: Step 9 attempted on Track page (Step 11) - navigation bug detected. Wizard footer: "${wizardFooter}"`; this.log('error', errorMsg); throw new Error(errorMsg); } } // Validate page state with selectors (both real and mock mode) const validation = await this.validatePageState({ expectedStep: 'cars', requiredSelectors: this.isRealMode() ? [IRACING_SELECTORS.steps.addCarButton] : ['#set-cars, .wizard-step[id*="cars"], .cars-panel'], // Mock mode: check for Cars container (fallbacks) forbiddenSelectors: ['#set-track'] }); if (validation.isErr()) { // Exception during validation const errorMsg = `Step 9 validation error: ${validation.error.message}`; this.log('error', errorMsg); throw new Error(errorMsg); } const validationResult = validation.unwrap(); this.log('info', 'Step 9 validation result', { isValid: validationResult.isValid, message: validationResult.message, missingSelectors: validationResult.missingSelectors, unexpectedSelectors: validationResult.unexpectedSelectors }); if (!validationResult.isValid) { // Validation failed - wrong page const errorMsg = `Step 9 FAILED validation: ${validationResult.message}. Browser is ${validationResult.unexpectedSelectors?.includes('#set-track') ? '3 steps ahead on Track page' : 'on wrong page'}`; this.log('error', errorMsg, { missing: validationResult.missingSelectors, unexpected: validationResult.unexpectedSelectors }); throw new Error(errorMsg); } this.log('info', 'Step 9 validation passed - confirmed on Cars page'); if (this.isRealMode()) { const carIds = config.carIds as string[] | undefined; const carSearchTerm = config.carSearch || config.car || carIds?.[0]; if (carSearchTerm) { await this.clickAddCarButton(); await this.waitForAddCarModal(); await this.fillField('carSearch', String(carSearchTerm)); await this.page!.waitForTimeout(500); await this.selectFirstSearchResult(); this.log('info', 'Added car to session', { car: carSearchTerm }); } // Navigate to Car Classes page await this.clickNextButton('Car Classes'); } else { // Mock mode if (config.carSearch) { await this.fillField('carSearch', String(config.carSearch)); await this.clickAction('confirm'); } // Navigate to Car Classes await this.clickNextButton('Car Classes'); } break; case 10: // Step 10: Car Classes - navigate to Track if (this.isRealMode()) { // Check for wizard auto-skip BEFORE trying to interact with the page const actualPage = await this.detectCurrentWizardPage(); const skipOffset = this.synchronizeStepCounter(step, actualPage); if (skipOffset > 0) { // Wizard skipped steps 8-10, we're already on step 11 (Track) this.log('info', `Step ${step} was auto-skipped by wizard`, { actualPage, skipOffset }); return { success: true }; } } await this.clickNextButton('Track'); break; case 11: // Step 11: Set Track (page already loaded by Step 9) // CRITICAL: Validate we're on Track page (both modes) this.log('info', 'Step 11: Validating page state before proceeding'); const step11Validation = await this.validatePageState({ expectedStep: 'track', requiredSelectors: ['#set-track'], // Both modes use same container ID forbiddenSelectors: this.isRealMode() ? [IRACING_SELECTORS.steps.addCarButton] : [] // Mock mode: no forbidden selectors needed }); if (step11Validation.isErr()) { // Exception during validation const errorMsg = `Step 11 validation error: ${step11Validation.error.message}`; this.log('error', errorMsg); throw new Error(errorMsg); } const step11ValidationResult = step11Validation.unwrap(); this.log('info', 'Step 11 validation result', { isValid: step11ValidationResult.isValid, message: step11ValidationResult.message, missingSelectors: step11ValidationResult.missingSelectors, unexpectedSelectors: step11ValidationResult.unexpectedSelectors }); if (!step11ValidationResult.isValid) { // Validation failed - wrong page const errorMsg = `Step 11 FAILED validation: ${step11ValidationResult.message}`; this.log('error', errorMsg, { missing: step11ValidationResult.missingSelectors, unexpected: step11ValidationResult.unexpectedSelectors }); throw new Error(errorMsg); } this.log('info', 'Step 11 validation passed - on Track page'); if (this.isRealMode()) { await this.waitForWizardStep('track'); await this.checkWizardDismissed(step); } // Track step now - continue with track logic break; case 12: // Step 12: Set Track if (this.isRealMode()) { await this.waitForWizardStep('track'); } // Just wait for the Track step and click next - track selection is a separate step await this.clickNextButton('Track Options'); break; case 13: // Step 13: Track Options if (this.isRealMode()) { // Auto-skip detection const actualPage = await this.detectCurrentWizardPage(); const skipOffset = this.synchronizeStepCounter(step, actualPage); if (skipOffset > 0) { this.log('info', `Step ${step} was auto-skipped by wizard`, { actualPage, skippedSteps: Array.from({ length: skipOffset }, (_, i) => step + i) }); return { success: true }; } await this.waitForWizardStep('trackOptions'); await this.checkWizardDismissed(step); const trackSearchTerm = config.trackSearch || config.track || config.trackId; if (trackSearchTerm) { // First, click the "Add Track" / "Select Track" button to open the modal await this.clickAddTrackButton(); // Wait for the modal to appear await this.waitForAddTrackModal(); // Search for the track await this.fillField('trackSearch', String(trackSearchTerm)); // Wait for search results to load await this.page!.waitForTimeout(500); // Select the first result by clicking its "Select" button // This immediately selects the track (no confirm step needed - modal closes automatically) await this.selectFirstSearchResult(); // After selecting track, wait for modal to actually close const modalClosed = await this.page!.waitForSelector('.modal.fade.in', { state: 'hidden', timeout: 5000 }) .then(() => true) .catch(() => false); if (!modalClosed) { this.log('warn', 'Track selection modal did not close, attempting dismiss'); await this.dismissModals(); await this.page!.waitForTimeout(300); } // Brief pause before attempting navigation await this.page!.waitForTimeout(300); this.log('info', 'Selected track for session', { track: trackSearchTerm }); } else { this.log('debug', 'Step 13: No track search term provided, skipping track addition'); } } else { // Mock mode behavior - add track if config provided if (config.trackSearch) { await this.fillField('trackSearch', String(config.trackSearch)); await this.clickAction('confirm'); } } // Verify navigation succeeded if (this.isRealMode()) { await this.waitForWizardStep('trackOptions'); } break; case 14: // Step 14: Time of Day if (this.isRealMode()) { // Auto-skip detection const actualPage = await this.detectCurrentWizardPage(); const skipOffset = this.synchronizeStepCounter(step, actualPage); if (skipOffset > 0) { this.log('info', `Step ${step} was auto-skipped by wizard`, { actualPage, skippedSteps: Array.from({ length: skipOffset }, (_, i) => step + i) }); return { success: true }; } await this.waitForWizardStep('timeOfDay'); await this.checkWizardDismissed(step); } if (config.trackConfig) { await this.selectDropdown('trackConfig', String(config.trackConfig)); } await this.clickNextButton('Time of Day'); // Verify navigation succeeded if (this.isRealMode()) { await this.waitForWizardStep('timeOfDay'); } break; case 15: // Step 15: Weather if (this.isRealMode()) { // Auto-skip detection const actualPage = await this.detectCurrentWizardPage(); const skipOffset = this.synchronizeStepCounter(step, actualPage); if (skipOffset > 0) { this.log('info', `Step ${step} was auto-skipped by wizard`, { actualPage, skippedSteps: Array.from({ length: skipOffset }, (_, i) => step + i) }); return { success: true }; } // Robust: try opening Weather via sidebar nav then wait for a set of fallback selectors. const weatherFallbackSelector = '#set-weather, .wizard-step[id*="weather"], .wizard-step[data-step="weather"], .weather-panel'; try { try { await this.page!.click('[data-testid="wizard-nav-set-weather"]'); this.log('debug', 'Clicked wizard nav for Weather', { selector: '[data-testid="wizard-nav-set-weather"]' }); } catch (e) { this.log('debug', 'Wizard nav for Weather not present (continuing)', { error: String(e) }); } try { await this.page!.waitForSelector(weatherFallbackSelector, { state: 'attached', timeout: 5000 }); this.log('info', 'Weather panel found', { selector: weatherFallbackSelector }); } catch (err) { this.log('warn', 'Weather panel not found with fallback selector, dumping #create-race-wizard innerHTML', { selector: weatherFallbackSelector }); const inner = await this.page!.evaluate(() => document.querySelector('#create-race-wizard')?.innerHTML || ''); this.log('debug', 'create-race-wizard innerHTML (truncated)', { html: inner ? inner.substring(0, 2000) : '' }); // Retry nav click once then wait longer before failing try { await this.page!.click('[data-testid="wizard-nav-set-weather"]'); } catch {} await this.page!.waitForSelector(weatherFallbackSelector, { state: 'attached', timeout: 10000 }); } } catch (e) { this.log('error', 'Failed waiting for Weather panel', { error: String(e), selector: weatherFallbackSelector }); } await this.checkWizardDismissed(step); } if (config.timeOfDay !== undefined) { await this.setSlider('timeOfDay', Number(config.timeOfDay)); } // Dismiss any open datetime pickers before clicking Next // The Time of Day step has React DateTime pickers that can intercept clicks if (this.isRealMode()) { await this.dismissDatetimePickers(); } await this.clickNextButton('Weather'); break; case 16: // Step 16: Race Options if (this.isRealMode()) { // Auto-skip detection const actualPage = await this.detectCurrentWizardPage(); const skipOffset = this.synchronizeStepCounter(step, actualPage); if (skipOffset > 0) { this.log('info', `Step ${step} was auto-skipped by wizard`, { actualPage, skippedSteps: Array.from({ length: skipOffset }, (_, i) => step + i) }); return { success: true }; } await this.waitForWizardStep('raceOptions'); await this.checkWizardDismissed(step); } if (config.weatherType && this.isRealMode()) { // Try to select weather type via Chakra radio button await this.selectWeatherType(String(config.weatherType)); } else if (config.weatherType && !this.isRealMode()) { // Mock mode uses dropdown await this.selectDropdown('weatherType', String(config.weatherType)); } if (config.temperature !== undefined) { // Temperature slider - only attempt if element exists const tempSelector = this.getSliderSelector('temperature'); const tempExists = await this.page!.locator(tempSelector).first().count() > 0; if (tempExists) { await this.setSlider('temperature', Number(config.temperature)); } else { this.log('debug', 'Temperature slider not found, skipping'); } } await this.clickNextButton('Track Conditions'); break; case 17: // Step 17: Track Conditions (final step with checkout confirmation flow) if (this.isRealMode()) { // Auto-skip detection const actualPage = await this.detectCurrentWizardPage(); const skipOffset = this.synchronizeStepCounter(step, actualPage); if (skipOffset > 0) { this.log('info', `Step ${step} was auto-skipped by wizard`, { actualPage, skippedSteps: Array.from({ length: skipOffset }, (_, i) => step + i) }); return { success: true }; } await this.waitForWizardStep('trackConditions'); await this.checkWizardDismissed(step); } if (config.trackState) { if (this.isRealMode()) { // Only try to set track state if it's provided, with graceful fallback try { const trackStateSelector = this.getDropdownSelector('trackState'); const exists = await this.page!.locator(trackStateSelector).first().count() > 0; if (exists) { await this.selectDropdown('trackState', String(config.trackState)); } else { this.log('debug', 'Track state dropdown not found, skipping'); } } catch (e) { this.log('debug', 'Could not set track state (non-critical)', { error: String(e) }); } } else { // Mock mode - try select dropdown first, fallback to setting slider/input if no select exists const trackStateSelector = this.getDropdownSelector('trackState'); const selectorExists = await this.page!.locator(trackStateSelector).first().count().catch(() => 0) > 0; if (selectorExists) { await this.selectDropdown('trackState', String(config.trackState)); } else { // Fallback for mock fixtures: set any slider/input that represents starting track state. // Map semantic names to approximate numeric slider values used in fixtures. const valueStr = String(config.trackState); await this.page!.evaluate((trackStateValue) => { const map: Record = { 'very-low': 10, 'low': 25, 'moderately-low': 40, 'medium': 50, 'moderately-high': 60, 'high': 75, 'very-high': 90 }; const numeric = map[trackStateValue] ?? null; // Find inputs whose id contains 'starting-track-state' or elements with data-value attr const inputs = Array.from(document.querySelectorAll('input[id*="starting-track-state"], input[id*="track-state"], input[data-value]')); if (numeric !== null && inputs.length > 0) { for (const inp of inputs) { try { inp.value = String(numeric); (inp as any).dataset = (inp as any).dataset || {}; (inp as any).dataset.value = String(numeric); inp.setAttribute('data-value', String(numeric)); inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); } catch (e) { // ignore individual failures } } } }, valueStr); } } } // Checkout confirmation flow (if callback is set) if (this.checkoutConfirmationCallback) { await this.handleCheckoutConfirmation(); } // Final step - if no callback, don't click next, user must review and confirm // Return success - step 17 complete return { success: true }; default: return { success: false, error: `Unknown step: ${step}` }; } // Show success on final step if (step === this.totalSteps && this.isRealMode()) { await this.showOverlayComplete(true); } return { success: true }; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); this.log('error', 'Step execution failed', { step, error: err.message }); // Show error on overlay if (this.isRealMode()) { await this.showOverlayComplete(false, `❌ Failed at step ${step}`); } // Save debug info (screenshot and HTML) on failure const debugPaths = await this.saveDebugInfo(`step-${step}`, err); // Include debug file paths in error message for easier debugging let errorMessage = err.message; if (debugPaths.screenshotPath || debugPaths.htmlPath) { const paths: string[] = []; if (debugPaths.screenshotPath) paths.push(`Screenshot: ${debugPaths.screenshotPath}`); if (debugPaths.htmlPath) paths.push(`HTML: ${debugPaths.htmlPath}`); errorMessage = `${err.message}\n\nDebug files:\n${paths.join('\n')}`; } // Throw error for validation failures (test expectations) // Return error object for other failures (backward compatibility) if (errorMessage.includes('validation') || errorMessage.includes('FAILED validation')) { throw new Error(errorMessage); } return { success: false, error: errorMessage }; } } /** * Step-to-Page mapping for wizard auto-skip detection. * Maps step numbers to their corresponding wizard page names. */ private static readonly STEP_TO_PAGE_MAP: Record = { 7: 'timeLimit', 8: 'cars', 9: 'cars', 10: 'carClasses', 11: 'track', 12: 'track', 13: 'trackOptions', 14: 'timeOfDay', 15: 'weather', 16: 'raceOptions', 17: 'trackConditions', }; /** * Detect which wizard page is currently displayed by checking container existence. * Returns the page name (e.g., 'cars', 'track') or null if no page is detected. * * This method checks each step container from IRACING_SELECTORS.wizard.stepContainers * and returns the first one that exists in the DOM. * * @returns Page name or null if unknown */ private async detectCurrentWizardPage(): Promise { if (!this.page) { return null; } try { // Check each container in stepContainers map const containers = IRACING_SELECTORS.wizard.stepContainers; for (const [pageName, selector] of Object.entries(containers)) { const count = await this.page.locator(selector).count(); if (count > 0) { this.log('debug', 'Detected wizard page', { pageName, selector }); return pageName; } } // No container found this.log('debug', 'No wizard page detected'); return null; } catch (error) { this.log('debug', 'Error detecting wizard page', { error: String(error) }); return null; } } /** * Synchronize step counter with actual wizard state. * Calculates the skip offset when wizard auto-skips steps (e.g., 8→11). * * @param expectedStep The step number we're trying to execute * @param actualPage The actual wizard page detected (from detectCurrentWizardPage) * @returns Skip offset (0 if no skip, >0 if steps were skipped) */ private synchronizeStepCounter(expectedStep: number, actualPage: string | null): number { if (!actualPage) { return 0; // Unknown state, no skip } // Find which step number corresponds to the actual page let actualStep: number | null = null; for (const [step, pageName] of Object.entries(PlaywrightAutomationAdapter.STEP_TO_PAGE_MAP)) { if (pageName === actualPage) { actualStep = parseInt(step, 10); break; // Use first match } } if (actualStep === null) { return 0; // Unknown page, no skip } // Calculate skip offset const skipOffset = actualStep - expectedStep; if (skipOffset > 0) { // Wizard skipped ahead - log warning with skipped step numbers const skippedSteps: number[] = []; for (let i = expectedStep; i < actualStep; i++) { skippedSteps.push(i); } this.log('warn', 'Wizard auto-skip detected', { expectedStep, actualStep, skipOffset, skippedSteps, }); return skipOffset; } // No skip or backward navigation return 0; } /** * Save debug information (screenshot and HTML) when a step fails. * Files are saved to debug-screenshots/ directory with timestamp. * Returns the paths of saved files for inclusion in error messages. * * Error dumps are always kept and not subject to cleanup. */ private async saveDebugInfo(stepName: string, error: Error): Promise<{ screenshotPath?: string; htmlPath?: string }> { if (!this.page) return {}; const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const baseName = `debug-error-${stepName}-${timestamp}`; const debugDir = path.join(process.cwd(), 'debug-screenshots'); const result: { screenshotPath?: string; htmlPath?: string } = {}; try { await fs.promises.mkdir(debugDir, { recursive: true }); // Save screenshot const screenshotPath = path.join(debugDir, `${baseName}.png`); await this.page.screenshot({ path: screenshotPath, fullPage: true }); result.screenshotPath = screenshotPath; this.log('error', `Error debug screenshot saved: ${screenshotPath}`, { path: screenshotPath, error: error.message }); // Save HTML const htmlPath = path.join(debugDir, `${baseName}.html`); const html = await this.page.content(); await fs.promises.writeFile(htmlPath, html); result.htmlPath = htmlPath; this.log('error', `Error debug HTML saved: ${htmlPath}`, { path: htmlPath }); } catch (e) { this.log('warn', 'Failed to save error debug info', { error: String(e) }); } return result; } /** * Save proactive debug information BEFORE step execution. * This ensures we always have the most recent state even if the browser is closed via Ctrl+C. * * Files are named with "before-step-N" prefix and old snapshots are cleaned up * to avoid disk bloat (keeps only last MAX_BEFORE_SNAPSHOTS). */ private async saveProactiveDebugInfo(step: number): Promise<{ screenshotPath?: string; htmlPath?: string }> { if (!this.page) return {}; const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const baseName = `debug-before-step-${step}-${timestamp}`; const debugDir = path.join(process.cwd(), 'debug-screenshots'); const result: { screenshotPath?: string; htmlPath?: string } = {}; try { await fs.promises.mkdir(debugDir, { recursive: true }); // Clean up old "before" snapshots first await this.cleanupOldBeforeSnapshots(debugDir); // Save screenshot const screenshotPath = path.join(debugDir, `${baseName}.png`); await this.page.screenshot({ path: screenshotPath, fullPage: true }); result.screenshotPath = screenshotPath; this.log('info', `Pre-step screenshot saved: ${screenshotPath}`, { path: screenshotPath, step }); // Save HTML const htmlPath = path.join(debugDir, `${baseName}.html`); const html = await this.page.content(); await fs.promises.writeFile(htmlPath, html); result.htmlPath = htmlPath; this.log('info', `Pre-step HTML saved: ${htmlPath}`, { path: htmlPath, step }); } catch (e) { // Don't fail step execution if debug save fails this.log('warn', 'Failed to save proactive debug info', { error: String(e), step }); } return result; } /** * Clean up old "before" debug snapshots to avoid disk bloat. * Keeps only the last MAX_BEFORE_SNAPSHOTS files (pairs of .png and .html). * * Error dumps (prefixed with "debug-error-") are never deleted. */ private async cleanupOldBeforeSnapshots(debugDir: string): Promise { try { const files = await fs.promises.readdir(debugDir); // Filter to only "before" snapshot files (not error dumps) const beforeFiles = files.filter(f => f.startsWith('debug-before-step-')); // Group by base name (without extension) to handle .png/.html pairs const baseNames = new Set(); for (const file of beforeFiles) { // Remove extension to get base name const baseName = file.replace(/\.(png|html)$/, ''); baseNames.add(baseName); } // Sort by timestamp (embedded in filename) - oldest first const sortedBaseNames = Array.from(baseNames).sort(); // Calculate how many pairs to delete const pairsToDelete = sortedBaseNames.length - PlaywrightAutomationAdapter.MAX_BEFORE_SNAPSHOTS; if (pairsToDelete > 0) { const baseNamesToDelete = sortedBaseNames.slice(0, pairsToDelete); for (const baseName of baseNamesToDelete) { // Delete both .png and .html files const pngPath = path.join(debugDir, `${baseName}.png`); const htmlPath = path.join(debugDir, `${baseName}.html`); try { if (fs.existsSync(pngPath)) { await fs.promises.unlink(pngPath); this.log('debug', `Cleaned up old snapshot: ${pngPath}`); } } catch { // Ignore deletion errors } try { if (fs.existsSync(htmlPath)) { await fs.promises.unlink(htmlPath); this.log('debug', `Cleaned up old snapshot: ${htmlPath}`); } } catch { // Ignore deletion errors } } this.log('debug', `Cleaned up ${pairsToDelete} old before-step snapshot pairs`); } } catch (e) { // Don't fail if cleanup fails this.log('debug', 'Failed to cleanup old snapshots', { error: String(e) }); } } /** * Dismiss any visible Chakra UI modal popups or datetime pickers that might block interactions. * This handles various modal dismiss patterns including close buttons and overlay clicks. * Also handles React DateTime picker (rdt) popups that can intercept pointer events. * Optimized for speed - uses instant visibility checks and minimal waits. */ private async dismissModals(): Promise { if (!this.page) return; try { // Check for Chakra UI modals (do NOT use this for datetime pickers - see dismissDatetimePickers) const modalContainer = this.page.locator('.chakra-modal__content-container'); const isModalVisible = await modalContainer.isVisible().catch(() => false); if (!isModalVisible) { this.log('debug', 'No modal visible, continuing'); return; } this.log('info', 'Modal detected, dismissing immediately'); // Try clicking Continue/Close/OK button with very short timeout const dismissButton = this.page.locator( '.chakra-modal__content-container button[aria-label="Continue"], ' + '.chakra-modal__content-container button:has-text("Continue"), ' + '.chakra-modal__content-container button:has-text("Close"), ' + '.chakra-modal__content-container button:has-text("OK"), ' + '.chakra-modal__close-btn, ' + '[aria-label="Close"]' ).first(); // Instant visibility check if (await dismissButton.isVisible().catch(() => false)) { this.log('info', 'Clicking modal dismiss button'); await dismissButton.click({ force: true, timeout: 1000 }); // Brief wait for modal to close (100ms) await this.page.waitForTimeout(100); return; } // Fallback: try Escape key this.log('debug', 'No dismiss button found, pressing Escape'); await this.page.keyboard.press('Escape'); await this.page.waitForTimeout(100); } catch (error) { this.log('debug', 'Modal dismiss error (non-critical)', { error: String(error) }); } } /** * Dismiss any open React DateTime pickers (rdt component). * These pickers can intercept pointer events and block clicks on other elements. * Used specifically before navigating away from steps that have datetime pickers. * * IMPORTANT: Do NOT use Escape key as it closes the entire wizard modal in iRacing. * * Strategy (in order of aggressiveness): * 1. Use JavaScript to remove 'rdtOpen' class directly (most reliable) * 2. Click outside the picker on the modal body * 3. Blur the active element */ private async dismissDatetimePickers(): Promise { if (!this.page) return; try { // Check for open datetime pickers (rdt component with class 'rdtOpen') const initialCount = await this.page.locator('.rdt.rdtOpen').count(); if (initialCount === 0) { this.log('debug', 'No datetime picker open'); return; } this.log('info', `Closing ${initialCount} open datetime picker(s)`); // Strategy 1: Use JavaScript to directly remove rdtOpen class // This is the most reliable method as it doesn't require clicking await this.page.evaluate(() => { const openPickers = document.querySelectorAll('.rdt.rdtOpen'); openPickers.forEach((picker) => { picker.classList.remove('rdtOpen'); }); // Also blur any focused inputs to prevent re-opening const activeEl = document.activeElement as HTMLElement; if (activeEl && activeEl.blur && activeEl.closest('.rdt')) { activeEl.blur(); } }); await this.page.waitForTimeout(50); // Verify pickers are closed let stillOpenCount = await this.page.locator('.rdt.rdtOpen').count(); if (stillOpenCount === 0) { this.log('debug', 'Datetime pickers closed via JavaScript'); return; } // Strategy 2: Click on the modal body outside the picker // This simulates clicking elsewhere to close the dropdown this.log('debug', `${stillOpenCount} picker(s) still open, clicking outside`); const modalBody = this.page.locator('.modal-body').first(); if (await modalBody.isVisible().catch(() => false)) { // Click at a safe spot - the header area of the card const cardHeader = this.page.locator('#set-time-of-day .card-header').first(); if (await cardHeader.isVisible().catch(() => false)) { await cardHeader.click({ force: true, timeout: 1000 }).catch(() => {}); await this.page.waitForTimeout(100); } } // Check again stillOpenCount = await this.page.locator('.rdt.rdtOpen').count(); if (stillOpenCount === 0) { this.log('debug', 'Datetime pickers closed via click outside'); return; } // Strategy 3: Force blur on all datetime inputs this.log('debug', `${stillOpenCount} picker(s) still open, force blur`); await this.page.evaluate(() => { // Blur all inputs inside rdt containers const rtdInputs = document.querySelectorAll('.rdt input'); rtdInputs.forEach((input) => { (input as HTMLElement).blur(); }); // Also force remove rdtOpen class again (in case React re-added it) const openPickers = document.querySelectorAll('.rdt.rdtOpen'); openPickers.forEach((picker) => { picker.classList.remove('rdtOpen'); // Also hide the picker element directly const pickerDropdown = picker.querySelector('.rdtPicker') as HTMLElement; if (pickerDropdown) { pickerDropdown.style.display = 'none'; } }); }); await this.page.waitForTimeout(50); // Final check const finalCount = await this.page.locator('.rdt.rdtOpen').count(); if (finalCount > 0) { this.log('warn', `Could not close ${finalCount} datetime picker(s), will attempt click with force`); } else { this.log('debug', 'Datetime picker dismiss complete'); } } catch (error) { this.log('debug', 'Datetime picker dismiss error (non-critical)', { error: String(error) }); } } /** * Check if a selector or element text matches blocked patterns (checkout/payment buttons). * SAFETY CRITICAL: This prevents accidental purchases during automation. * * @param selector The CSS selector being clicked * @param elementText Optional text content of the element (should be direct text only) * @returns true if the selector/text matches a blocked pattern */ private isBlockedSelector(selector: string, elementText?: string): boolean { const selectorLower = selector.toLowerCase(); const textLower = elementText?.toLowerCase().trim() ?? ''; // Check if selector contains any blocked keywords for (const keyword of BLOCKED_KEYWORDS) { if (selectorLower.includes(keyword) || textLower.includes(keyword)) { return true; } } // Check for price indicators (e.g., "$0.50", "$19.99") // IMPORTANT: Only block if the price is combined with a checkout-related action word // This prevents false positives when price is merely displayed on the page const pricePattern = /\$\d+\.\d{2}/; const hasPrice = pricePattern.test(textLower) || pricePattern.test(selector); if (hasPrice) { // Only block if text also contains checkout-related words const checkoutActionWords = ['check', 'out', 'buy', 'purchase', 'pay', 'cart']; const hasCheckoutWord = checkoutActionWords.some(word => textLower.includes(word)); if (hasCheckoutWord) { return true; } } // Check for cart icon class if (selectorLower.includes('icon-cart') || selectorLower.includes('cart-icon')) { return true; } return false; } /** * Verify an element is not a blocked checkout/payment button before clicking. * SAFETY CRITICAL: Throws error if element matches blocked patterns. * * This method checks: * 1. The selector string itself for blocked patterns * 2. The element's DIRECT text content (not children/siblings) * 3. The element's class, id, and href attributes for checkout indicators * 4. Whether the element matches any blocked CSS selectors * * @param selector The CSS selector of the element to verify * @throws Error if element is a blocked checkout/payment button */ private async verifyNotBlockedElement(selector: string): Promise { if (!this.page) return; // In mock mode we bypass safety blocking to allow tests to exercise checkout flows // without risking real-world purchases. Safety checks remain active in 'real' mode. if (!this.isRealMode()) { this.log('debug', 'Mock mode detected - skipping checkout blocking checks', { selector }); return; } // First check the selector itself if (this.isBlockedSelector(selector)) { const errorMsg = `🚫 BLOCKED: Selector "${selector}" matches checkout/payment pattern. Automation stopped for safety.`; this.log('error', errorMsg); throw new Error(errorMsg); } // Try to get the element's attributes and direct text for verification try { const element = this.page.locator(selector).first(); const isVisible = await element.isVisible().catch(() => false); if (isVisible) { // Get element attributes for checking const elementClass = await element.getAttribute('class').catch(() => '') ?? ''; const elementId = await element.getAttribute('id').catch(() => '') ?? ''; const elementHref = await element.getAttribute('href').catch(() => '') ?? ''; // Check class/id/href for checkout indicators const attributeText = `${elementClass} ${elementId} ${elementHref}`.toLowerCase(); if (attributeText.includes('checkout') || attributeText.includes('cart') || attributeText.includes('purchase') || attributeText.includes('payment')) { const errorMsg = `🚫 BLOCKED: Element attributes contain checkout pattern. Class="${elementClass}", ID="${elementId}", Href="${elementHref}". Automation stopped for safety.`; this.log('error', errorMsg); throw new Error(errorMsg); } // Get ONLY the direct text of this element, excluding child element text // This prevents false positives when a checkout button exists elsewhere on the page const directText = await element.evaluate((el) => { // Get only direct text nodes, not text from child elements let text = ''; const childNodes = Array.from(el.childNodes); for (let i = 0; i < childNodes.length; i++) { const node = childNodes[i]; if (node.nodeType === Node.TEXT_NODE) { text += node.textContent || ''; } } return text.trim(); }).catch(() => ''); // Also get innerText as fallback (for buttons with icon + text structure) // But only check if directText is empty or very short let textToCheck = directText; if (directText.length < 3) { // For elements like // We need innerText but should be careful about what we capture const innerText = await element.innerText().catch(() => ''); // Only use innerText if it's reasonably short (not entire page sections) if (innerText.length < 100) { textToCheck = innerText.trim(); } } this.log('debug', 'Checking element text for blocked patterns', { selector, directText, textToCheck, elementClass, }); if (textToCheck && this.isBlockedSelector('', textToCheck)) { const errorMsg = `🚫 BLOCKED: Element text "${textToCheck}" matches checkout/payment pattern. Automation stopped for safety.`; this.log('error', errorMsg); throw new Error(errorMsg); } // Check if element matches any of the blocked selectors directly for (const blockedSelector of Object.values(IRACING_SELECTORS.BLOCKED_SELECTORS)) { const matchesBlocked = await element.evaluate((el, sel) => { try { return el.matches(sel) || el.closest(sel) !== null; } catch { return false; } }, blockedSelector).catch(() => false); if (matchesBlocked) { const errorMsg = `🚫 BLOCKED: Element matches blocked selector "${blockedSelector}". Automation stopped for safety.`; this.log('error', errorMsg); throw new Error(errorMsg); } } } } catch (error) { // If error is our blocked error, re-throw it if (error instanceof Error && error.message.includes('BLOCKED')) { throw error; } // Otherwise ignore - element might not exist yet, safeClick will handle that this.log('debug', 'Could not verify element (may not exist yet)', { selector, error: String(error) }); } } /** * Safe click wrapper that handles modal interception errors with auto-retry. * If a click fails because a modal is intercepting pointer events, this method * will dismiss the modal and retry the click operation. * * SAFETY: Before any click, verifies the target is not a checkout/payment button. * * @param selector The CSS selector of the element to click * @param options Click options including timeout and force * @returns Promise that resolves when click succeeds or throws after max retries */ private async safeClick(selector: string, options?: { timeout?: number; force?: boolean }): Promise { if (!this.page) { throw new Error('Browser not connected'); } // SAFETY CHECK: Verify this is not a checkout/payment button await this.verifyNotBlockedElement(selector); const maxRetries = 3; const timeout = options?.timeout ?? this.config.timeout; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { // On final attempt, use force: true if datetime picker issues detected const useForce = options?.force || attempt === maxRetries; await this.page.click(selector, { timeout, force: useForce }); return; // Success } catch (error) { // Re-throw blocked errors immediately if (error instanceof Error && error.message.includes('BLOCKED')) { throw error; } const errorMessage = String(error); // Check if a modal or datetime picker is intercepting the click if ( errorMessage.includes('intercepts pointer events') || errorMessage.includes('chakra-modal') || errorMessage.includes('chakra-portal') || errorMessage.includes('rdtDay') || errorMessage.includes('rdtPicker') || errorMessage.includes('rdt') ) { this.log('info', `Element intercepting click (attempt ${attempt}/${maxRetries}), dismissing...`, { selector, attempt, maxRetries, }); // Try dismissing datetime pickers first (common cause of interception) await this.dismissDatetimePickers(); // Then try dismissing modals await this.dismissModals(); await this.page.waitForTimeout(200); // Brief wait for DOM to settle if (attempt === maxRetries) { // Last attempt already tried with force: true, so if we're here it really failed this.log('error', 'Max retries reached, click still blocked', { selector }); throw error; // Give up after max retries } // Continue to retry } else { // Different error, don't retry throw error; } } } } /** * Select weather type via Chakra UI radio button. * iRacing's modern UI uses a radio group with options: * - "Static Weather" (value: 2, checked by default) * - "Forecasted weather" (value: 1) * - "Timeline editor" (value: 3) * * @param weatherType The weather type to select (e.g., "static", "forecasted", "timeline", or the value) */ private async selectWeatherType(weatherType: string): Promise { if (!this.page) { throw new Error('Browser not connected'); } try { this.log('info', 'Selecting weather type via radio button', { weatherType }); // Map common weather type names to selectors const weatherTypeLower = weatherType.toLowerCase(); let labelSelector: string; if (weatherTypeLower.includes('static') || weatherType === '2') { labelSelector = 'label.chakra-radio:has-text("Static Weather")'; } else if (weatherTypeLower.includes('forecast') || weatherType === '1') { labelSelector = 'label.chakra-radio:has-text("Forecasted weather")'; } else if (weatherTypeLower.includes('timeline') || weatherTypeLower.includes('custom') || weatherType === '3') { labelSelector = 'label.chakra-radio:has-text("Timeline editor")'; } else { // Default to static weather labelSelector = 'label.chakra-radio:has-text("Static Weather")'; this.log('warn', `Unknown weather type "${weatherType}", defaulting to Static Weather`); } // Check if radio group exists (weather step might be optional) const radioGroup = this.page.locator('[role="radiogroup"]').first(); const exists = await radioGroup.count() > 0; if (!exists) { this.log('debug', 'Weather radio group not found, step may be optional'); return; } // Click the radio button label const radioLabel = this.page.locator(labelSelector).first(); const isVisible = await radioLabel.isVisible().catch(() => false); if (isVisible) { await radioLabel.click({ timeout: IRACING_TIMEOUTS.elementWait }); this.log('info', 'Selected weather type', { weatherType, selector: labelSelector }); } else { this.log('debug', 'Weather type radio not visible, may already be selected or step is different'); } } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('warn', 'Could not select weather type (non-critical)', { error: message, weatherType }); // Don't throw - weather type selection is optional } } /** * Check if the "Add Admin" modal is currently visible. * This modal appears when the user clicks "Add Admin" on the Admins step. * @returns true if the admin modal is visible, false otherwise */ private async isAdminModalVisible(): Promise { if (!this.page) return false; try { // Look for a modal with admin-related content // The admin modal should have a search input and be separate from the main wizard modal const adminModalSelector = '#set-admins .modal, .modal:has(input[placeholder*="Search"]):has-text("Admin")'; const isVisible = await this.page.locator(adminModalSelector).first().isVisible().catch(() => false); return isVisible; } catch { return false; } } /** * Check if the "Add Car" modal is currently visible. * This modal appears when the user clicks "Add Car" on the Cars step. * @returns true if the car modal is visible, false otherwise */ private async isCarModalVisible(): Promise { if (!this.page) return false; try { // Look for a modal with car-related content // The car modal should have a search input and be part of the set-cars step const carModalSelector = '#set-cars .modal, .modal:has(input[placeholder*=\"Search\"]):has-text(\"Car\")'; const isVisible = await this.page.locator(carModalSelector).first().isVisible().catch(() => false); return isVisible; } catch { return false; } } /** * Check if the "Add Track" modal is currently visible. * This modal appears when the user clicks "Add Track" on the Track step. * @returns true if the track modal is visible, false otherwise */ private async isTrackModalVisible(): Promise { if (!this.page) return false; try { // Look for a modal with track-related content // The track modal should have a search input and be part of the set-track step const trackModalSelector = '#set-track .modal, .modal:has(input[placeholder*=\"Search\"]):has-text(\"Track\")'; const isVisible = await this.page.locator(trackModalSelector).first().isVisible().catch(() => false); return isVisible; } catch { return false; } } /** * Click the "Add Car" button to open the Add Car modal. * This button is located on the Set Cars step (Step 8). */ private async clickAddCarButton(): Promise { if (!this.page) { throw new Error('Browser not connected'); } const addCarButtonSelector = this.isRealMode() ? IRACING_SELECTORS.steps.addCarButton : '[data-action="add-car"]'; try { this.log('info', 'Clicking Add Car button to open modal'); // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" await this.page.waitForSelector(addCarButtonSelector, { state: 'attached', timeout: this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout, }); await this.safeClick(addCarButtonSelector); this.log('info', 'Clicked Add Car button'); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', 'Could not click Add Car button', { error: message }); throw new Error(`Failed to click Add Car button: ${message}`); } } /** * Wait for the Add Car modal to appear. */ private async waitForAddCarModal(): Promise { if (!this.page) { throw new Error('Browser not connected'); } try { this.log('debug', 'Waiting for Add Car modal to appear'); // Wait for modal container - use 'attached' because iRacing wizard steps have class="hidden" const modalSelector = IRACING_SELECTORS.steps.addCarModal; await this.page.waitForSelector(modalSelector, { state: 'attached', timeout: this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout, }); // Brief pause for modal animation (reduced from 300ms) await this.page.waitForTimeout(150); this.log('info', 'Add Car modal is visible'); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('warn', 'Add Car modal did not appear', { error: message }); // Don't throw - modal might appear differently in real iRacing } } /** * Click the "Add Track" / "Select Track" button to open the Add Track modal. * This button is located on the Set Track step (Step 11). */ private async clickAddTrackButton(): Promise { if (!this.page) { throw new Error('Browser not connected'); } const addTrackButtonSelector = IRACING_SELECTORS.steps.addTrackButton; try { this.log('info', 'Clicking Add Track button to open modal'); // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" await this.page.waitForSelector(addTrackButtonSelector, { state: 'attached', timeout: this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout, }); await this.safeClick(addTrackButtonSelector); this.log('info', 'Clicked Add Track button'); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', 'Could not click Add Track button', { error: message }); throw new Error(`Failed to click Add Track button: ${message}`); } } /** * Wait for the Add Track modal to appear. */ private async waitForAddTrackModal(): Promise { if (!this.page) { throw new Error('Browser not connected'); } try { this.log('debug', 'Waiting for Add Track modal to appear'); // Wait for modal container - use 'attached' because iRacing wizard steps have class="hidden" const modalSelector = IRACING_SELECTORS.steps.addTrackModal; await this.page.waitForSelector(modalSelector, { state: 'attached', timeout: this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout, }); // Brief pause for modal animation (reduced from 300ms) await this.page.waitForTimeout(150); this.log('info', 'Add Track modal is visible'); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('warn', 'Add Track modal did not appear', { error: message }); // Don't throw - modal might appear differently in real iRacing } } /** * Select the first search result in the current modal by clicking its "Select" button. * In iRacing's Add Car/Track modals, search results are displayed in a table, * and each row has a "Select" button (a.btn.btn-block.btn-primary.btn-xs). * * Two button patterns exist: * 1. Direct select (single-config tracks): a.btn.btn-primary.btn-xs:not(.dropdown-toggle) * 2. Dropdown (multi-config tracks): a.btn.btn-primary.btn-xs.dropdown-toggle → opens menu → click .dropdown-item * * Clicking "Select" immediately adds the item - there is no confirm step. */ private async selectFirstSearchResult(): Promise { if (!this.page) { throw new Error('Browser not connected'); } // First try direct select button (non-dropdown) const directSelector = '.modal table a.btn.btn-primary.btn-xs:not(.dropdown-toggle)'; const directButton = this.page.locator(directSelector).first(); if (await directButton.count() > 0 && await directButton.isVisible()) { await this.safeClick(directSelector, { timeout: IRACING_TIMEOUTS.elementWait }); this.log('info', 'Clicked direct Select button for first search result'); return; } // Fallback: dropdown toggle pattern const dropdownSelector = '.modal table a.btn.btn-primary.btn-xs.dropdown-toggle'; const dropdownButton = this.page.locator(dropdownSelector).first(); if (await dropdownButton.count() > 0 && await dropdownButton.isVisible()) { // Click dropdown to open menu await this.safeClick(dropdownSelector, { timeout: IRACING_TIMEOUTS.elementWait }); this.log('debug', 'Clicked dropdown toggle, waiting for menu'); // Wait for dropdown menu to appear await this.page.waitForSelector('.dropdown-menu.show', { timeout: 3000 }).catch(() => {}); // Click first item in dropdown (first track config) const itemSelector = '.dropdown-menu.show .dropdown-item:first-child'; await this.page.waitForTimeout(200); await this.safeClick(itemSelector, { timeout: IRACING_TIMEOUTS.elementWait }); this.log('info', 'Clicked first dropdown item to select track config'); return; } // If neither found, throw error throw new Error('No Select button found in modal table'); } // NOTE: clickCarModalConfirm() and clickTrackModalConfirm() have been removed. // The Add Car/Track modals use a table with "Select" buttons that immediately add the item. // There is no separate confirm step - clicking "Select" closes the modal and adds the car/track. // The selectFirstSearchResult() method now handles clicking the "Select" button directly. /** * Click the confirm/select button in the "Add Admin" modal. * Uses a specific selector that avoids the checkout button. */ private async clickAdminModalConfirm(): Promise { if (!this.page) { throw new Error('Browser not connected'); } // Use a selector specific to the admin modal, NOT the main wizard modal footer // The admin modal confirm button should be inside the admin modal content const adminConfirmSelector = '#set-admins .modal .btn-primary, #set-admins .modal button:has-text("Add"), #set-admins .modal button:has-text("Select")'; try { // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" await this.page.waitForSelector(adminConfirmSelector, { state: 'attached', timeout: IRACING_TIMEOUTS.elementWait, }); await this.safeClick(adminConfirmSelector, { timeout: IRACING_TIMEOUTS.elementWait }); this.log('info', 'Clicked admin modal confirm button'); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('warn', 'Could not click admin modal confirm button', { error: message }); throw new Error(`Failed to confirm admin selection: ${message}`); } } /** * Click the "New Race" button in the modal that appears after clicking "Create a Race". * This modal asks whether to use "Last Settings" or "New Race". */ private async clickNewRaceInModal(): Promise { if (!this.page) { throw new Error('Browser not connected'); } try { this.log('info', 'Waiting for Create Race modal to appear'); // Wait for the modal - use 'attached' because iRacing elements may have class="hidden" const modalSelector = IRACING_SELECTORS.hostedRacing.createRaceModal; await this.page.waitForSelector(modalSelector, { state: 'attached', timeout: IRACING_TIMEOUTS.elementWait, }); this.log('info', 'Create Race modal attached, clicking New Race button'); // Click the "New Race" button - use 'attached' for consistency const newRaceSelector = IRACING_SELECTORS.hostedRacing.newRaceButton; await this.page.waitForSelector(newRaceSelector, { state: 'attached', timeout: IRACING_TIMEOUTS.elementWait, }); await this.safeClick(newRaceSelector, { timeout: IRACING_TIMEOUTS.elementWait }); this.log('info', 'Clicked New Race button, waiting for form to load'); // Wait a moment for the form to load await this.page.waitForTimeout(500); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', 'Failed to click New Race in modal', { error: message }); throw new Error(`Failed to click New Race button: ${message}`); } } /** * Handle login for real iRacing website. * First checks if user is already authenticated - if so, navigates directly to hosted sessions. */ private async injectCookiesBeforeNavigation(targetUrl: string): Promise> { if (!this.persistentContext && !this.context) { return Result.err(new Error('No browser context available')); } try { // Read cookies from store const state = await this.cookieStore.read(); if (!state || state.cookies.length === 0) { return Result.err(new Error('No cookies found in session store')); } // Get only cookies that are valid for target URL // This filters out cookies from other domains (e.g., oauth.iracing.com, members.iracing.com) // and only injects cookies that match the target domain const validCookies = this.cookieStore.getValidCookiesForUrl(targetUrl); if (validCookies.length === 0) { this.log('warn', 'No valid cookies found for target URL', { targetUrl, totalCookies: state.cookies.length, }); return Result.err(new Error('No valid cookies found for target URL')); } // Inject cookies into context BEFORE navigation const context = this.persistentContext || this.context; await context!.addCookies(validCookies); this.log('info', 'Cookies injected successfully', { count: validCookies.length, targetUrl, cookieNames: validCookies.map((c) => c.name), }); return Result.ok(undefined); } catch (error) { const message = error instanceof Error ? error.message : String(error); return Result.err(new Error(`Cookie injection failed: ${message}`)); } } async verifyPageAuthentication(): Promise> { if (!this.page) { return Result.err(new Error('Browser not connected')); } try { // Check current URL - if we're on an authenticated page path, we're authenticated const url = this.page.url(); const isOnAuthenticatedPath = url.includes('/web/racing/hosted') || url.includes('/membersite/member') || url.includes('/members-ng.iracing.com'); const isOnLoginPath = url.includes('/login') || url.includes('oauth.iracing.com'); // Check for login UI indicators const guard = new AuthenticationGuard(this.page, this.logger); const hasLoginUI = await guard.checkForLoginUI(); // Check for authenticated UI indicators // Look for elements that are ONLY present when authenticated const authSelectors = [ 'button:has-text("Create a Race")', '[aria-label="Create a Race"]', // User menu/profile indicators (present on ALL authenticated pages) '[aria-label*="user menu" i]', '[aria-label*="account menu" i]', '.user-menu', '.account-menu', // iRacing-specific: members navigation 'nav a[href*="/membersite"]', 'nav a[href*="/members"]', ]; let hasAuthUI = false; for (const selector of authSelectors) { try { const element = this.page.locator(selector).first(); const isVisible = await element.isVisible().catch(() => false); if (isVisible) { this.log('info', 'Authenticated UI detected', { selector }); hasAuthUI = true; break; } } catch { // Selector not found, continue } } // Check cookies const cookieResult = await this.checkSession(); const cookiesValid = cookieResult.isOk() && cookieResult.unwrap() === AuthenticationState.AUTHENTICATED; // Determine page authentication state // Priority order: // 1. If on authenticated path and cookies valid, we're authenticated // 2. If we see authenticated UI, we're authenticated // 3. If not on login path and no login UI, we're authenticated const pageAuthenticated = (isOnAuthenticatedPath && !isOnLoginPath && cookiesValid) || hasAuthUI || (!hasLoginUI && !isOnLoginPath); this.log('debug', 'Page authentication check', { url, isOnAuthenticatedPath, isOnLoginPath, hasLoginUI, hasAuthUI, cookiesValid, pageAuthenticated, }); return Result.ok(new BrowserAuthenticationState(cookiesValid, pageAuthenticated)); } catch (error) { const message = error instanceof Error ? error.message : String(error); return Result.err(new Error(`Page verification failed: ${message}`)); } } /** * Handle login for real iRacing website. * First checks if user is already authenticated - if so, navigates directly to hosted sessions. * Otherwise navigates to login page and waits for user to complete manual login. */ private async handleLogin(): Promise { try { // Check session cookies FIRST before launching browser const sessionResult = await this.checkSession(); if ( sessionResult.isOk() && sessionResult.unwrap() === AuthenticationState.AUTHENTICATED ) { // Valid cookies exist - use configured browser mode (headless/headed) this.log('info', 'Session cookies found, launching in configured browser mode'); await this.ensureBrowserContext(false); // Use configured mode if (!this.page) { return { success: false, error: 'Browser not connected' }; } // Inject cookies BEFORE navigation const injectResult = await this.injectCookiesBeforeNavigation( IRACING_URLS.hostedSessions ); if (injectResult.isErr()) { this.log('warn', 'Cookie injection failed, switching to manual login', { error: injectResult.error.message, }); // Fall through to manual login flow below } else { // Navigate with cookies injected await this.page.goto(IRACING_URLS.hostedSessions, { waitUntil: 'domcontentloaded', timeout: IRACING_TIMEOUTS.navigation, }); // Verify page shows authenticated state const verifyResult = await this.verifyPageAuthentication(); if (verifyResult.isOk()) { const browserState = verifyResult.unwrap(); if (browserState.isFullyAuthenticated()) { this.log('info', 'Authentication verified successfully'); return { success: true }; } else { this.log('warn', 'Page shows unauthenticated state despite cookies'); // Fall through to manual login flow below } } } } // No valid cookies or cookie injection failed - need manual login // Close existing browser if running in headless mode // Must restart in headed mode so user can see and interact with login page if (this.actualBrowserMode === 'headless' && (this.browser || this.persistentContext)) { this.log('info', '[Auth] Closing headless browser to restart in headed mode for manual login'); await this.closeBrowserContext(); } // Ensure browser context is ready, forcing headed mode for manual login await this.ensureBrowserContext(true); if (!this.page) { return { success: false, error: 'Browser not connected after restart' }; } // Not authenticated - proceed with login flow this.log('info', 'Not authenticated, navigating to iRacing login page'); await this.page.goto(IRACING_URLS.login, { waitUntil: 'domcontentloaded', timeout: IRACING_TIMEOUTS.navigation, }); this.log('info', 'Waiting for user to complete login (max 2 minutes)...'); // Wait for navigation to hosted sessions page await this.page.waitForURL('**/hostedsessions**', { timeout: IRACING_TIMEOUTS.loginWait, }); this.log('info', 'Login successful, now on hosted sessions page'); return { success: true }; } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', 'Login failed or timed out', { error: message }); return { success: false, error: `Login failed: ${message}` }; } } async waitForStep(stepNumber: number): Promise { if (!this.page) { throw new Error('Browser not connected'); } // In mock mode, update the data-step attribute on body to reflect current step // This is needed for getCurrentStep() to work correctly in tests if (!this.isRealMode()) { await this.page.evaluate((step) => { document.body.setAttribute('data-step', String(step)); }, stepNumber); } const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" await this.page.waitForSelector(`[data-step="${stepNumber}"]`, { state: 'attached', timeout, }); } /** * Wait for a specific wizard step to be visible in real mode. * Uses the step container IDs from IRACING_SELECTORS.wizard.stepContainers */ private async waitForWizardStep(stepName: keyof typeof IRACING_SELECTORS.wizard.stepContainers): Promise { if (!this.page || !this.isRealMode()) return; const containerSelector = IRACING_SELECTORS.wizard.stepContainers[stepName]; if (!containerSelector) { this.log('warn', `Unknown wizard step: ${stepName}`); return; } try { this.log('debug', `Waiting for wizard step: ${stepName}`, { selector: containerSelector }); // Use 'attached' instead of 'visible' because iRacing wizard steps are marked as // 'active hidden' in the DOM - they exist but are hidden via CSS class await this.page.waitForSelector(containerSelector, { state: 'attached', timeout: 15000, }); // Brief pause to ensure DOM is settled await this.page.waitForTimeout(100); } catch (error) { this.log('warn', `Wizard step not attached: ${stepName}`, { error: String(error) }); // Don't throw - step might be combined with another or skipped } } /** * Fill a form field with fallback selector support. * Tries the primary selector first, then falls back to alternative selectors. * This is needed because iRacing's form structure can vary slightly. */ private async fillFieldWithFallback(fieldName: string, value: string): Promise { if (!this.page) { return { success: false, fieldName, value, error: 'Browser not connected' }; } const selector = this.getFieldSelector(fieldName); const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; // Split combined selectors and try each one const selectors = selector.split(', ').map(s => s.trim()); for (const sel of selectors) { try { this.log('debug', `Trying selector for ${fieldName}`, { selector: sel }); // Check if element exists and is visible const element = this.page.locator(sel).first(); const isVisible = await element.isVisible().catch(() => false); if (isVisible) { // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" await element.waitFor({ state: 'attached', timeout }); await element.fill(value); this.log('info', `Successfully filled ${fieldName}`, { selector: sel, value }); return { success: true, fieldName, value }; } } catch (error) { this.log('debug', `Selector failed for ${fieldName}`, { selector: sel, error: String(error) }); } } // If none worked, try the original combined selector (Playwright handles comma-separated) try { this.log('debug', `Trying combined selector for ${fieldName}`, { selector }); // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" await this.page.waitForSelector(selector, { state: 'attached', timeout }); await this.page.fill(selector, value); return { success: true, fieldName, value }; } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', `Failed to fill ${fieldName}`, { selector, error: message }); return { success: false, fieldName, value, error: message }; } } /** * Click the "Next" button in the wizard footer. * In real iRacing, the next button text shows the next step name (e.g., "Server Details"). * @param nextStepName The name of the next step (for logging and fallback) */ private async clickNextButton(nextStepName: string): Promise { if (!this.page) { throw new Error('Browser not connected'); } if (!this.isRealMode()) { // Mock mode uses data-action="next" await this.clickAction('next'); return; } const timeout = IRACING_TIMEOUTS.elementWait; // Primary: Look for the next button with caret icon (it points to next step) const nextButtonSelector = IRACING_SELECTORS.wizard.nextButton; // Fallback: Look for button with the next step name const fallbackSelector = `.wizard-footer a.btn:has-text("${nextStepName}")`; try { // Try primary selector first this.log('debug', 'Looking for next button', { selector: nextButtonSelector }); const nextButton = this.page.locator(nextButtonSelector).first(); const isVisible = await nextButton.isVisible().catch(() => false); if (isVisible) { await this.safeClick(nextButtonSelector, { timeout }); this.log('info', `Clicked next button to ${nextStepName}`); return; } // Try fallback with step name this.log('debug', 'Trying fallback next button', { selector: fallbackSelector }); const fallback = this.page.locator(fallbackSelector).first(); const fallbackVisible = await fallback.isVisible().catch(() => false); if (fallbackVisible) { await this.safeClick(fallbackSelector, { timeout }); this.log('info', `Clicked next button (fallback) to ${nextStepName}`); return; } // Last resort: any non-disabled button in wizard footer const lastResort = '.wizard-footer a.btn:not(.disabled):last-child'; await this.safeClick(lastResort, { timeout }); this.log('info', `Clicked next button (last resort) to ${nextStepName}`); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', `Failed to click next button to ${nextStepName}`, { error: message }); throw new Error(`Failed to navigate to ${nextStepName}: ${message}`); } } async clickAction(action: string): Promise { if (!this.page) { return { success: false, error: 'Browser not connected' }; } const selector = this.getActionSelector(action); const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" await this.page.waitForSelector(selector, { state: 'attached', timeout }); await this.safeClick(selector, { timeout }); return { success: true }; } async fillField(fieldName: string, value: string): Promise { if (!this.page) { return { success: false, fieldName, value, error: 'Browser not connected' }; } const selector = this.getFieldSelector(fieldName); const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" await this.page.waitForSelector(selector, { state: 'attached', timeout }); await this.page.fill(selector, value); return { success: true, fieldName, value }; } async selectDropdown(name: string, value: string): Promise { if (!this.page) { throw new Error('Browser not connected'); } const selector = this.getDropdownSelector(name); const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" // on the container - elements are in DOM but not visible via CSS await this.page.waitForSelector(selector, { state: 'attached', timeout }); await this.page.selectOption(selector, value); } private getDropdownSelector(name: string): string { const dropdownMap: Record = { region: IRACING_SELECTORS.steps.region, trackConfig: IRACING_SELECTORS.steps.trackConfig, weatherType: IRACING_SELECTORS.steps.weatherType, trackState: IRACING_SELECTORS.steps.trackState, }; return dropdownMap[name] || IRACING_SELECTORS.fields.select; } async setToggle(name: string, checked: boolean): Promise { if (!this.page) { throw new Error('Browser not connected'); } const selector = this.getToggleSelector(name); const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" // on the container - elements are in DOM but not visible via CSS await this.page.waitForSelector(selector, { state: 'attached', timeout }); const isChecked = await this.page.isChecked(selector); if (isChecked !== checked) { await this.safeClick(selector, { timeout }); } } private getToggleSelector(name: string): string { const toggleMap: Record = { startNow: IRACING_SELECTORS.steps.startNow, rollingStart: IRACING_SELECTORS.steps.rollingStart, }; return toggleMap[name] || IRACING_SELECTORS.fields.checkbox; } async setSlider(name: string, value: number): Promise { if (!this.page) { throw new Error('Browser not connected'); } const selector = this.getSliderSelector(name); const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" // on the container - elements are in DOM but not visible via CSS await this.page.waitForSelector(selector, { state: 'attached', timeout }); await this.page.fill(selector, String(value)); } private getSliderSelector(name: string): string { const sliderMap: Record = { practice: IRACING_SELECTORS.steps.practice, qualify: IRACING_SELECTORS.steps.qualify, race: IRACING_SELECTORS.steps.race, timeOfDay: IRACING_SELECTORS.steps.timeOfDay, temperature: IRACING_SELECTORS.steps.temperature, }; return sliderMap[name] || IRACING_SELECTORS.fields.slider; } async waitForModal(): Promise { if (!this.page) { throw new Error('Browser not connected'); } const selector = IRACING_SELECTORS.wizard.modal; const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" await this.page.waitForSelector(selector, { state: 'attached', timeout, }); } async selectListItem(itemId: string): Promise { if (!this.page) { throw new Error('Browser not connected'); } const selector = `[data-item="${itemId}"], button:has-text("${itemId}")`; const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" await this.page.waitForSelector(selector, { state: 'attached', timeout }); await this.safeClick(selector, { timeout }); } async openModalTrigger(type: string): Promise { if (!this.page) { throw new Error('Browser not connected'); } const selector = `button:has-text("${type}"), [aria-label*="${type}" i]`; const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; // Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden" await this.page.waitForSelector(selector, { state: 'attached', timeout }); await this.safeClick(selector, { timeout }); } async getCurrentStep(): Promise { if (!this.page) { return null; } if (this.isRealMode()) { // In real mode, we can't reliably determine step from DOM // Return null and let higher-level logic track step state return null; } const stepAttr = await this.page.getAttribute('body', 'data-step'); return stepAttr ? parseInt(stepAttr, 10) : null; } getMode(): AutomationAdapterMode { return this.config.mode; } getPage(): Page | null { return this.page; } // ===== IAuthenticationService Implementation ===== /** * Check if user has a valid session by reading the cookie store JSON file. * NO BROWSER LAUNCH - just reads the persisted storage state. */ async checkSession(): Promise> { try { this.log('info', 'Checking iRacing session from cookie store'); const state = await this.cookieStore.read(); if (!state) { this.authState = AuthenticationState.UNKNOWN; this.log('info', 'No session state file found'); return Result.ok(this.authState); } this.authState = this.cookieStore.validateCookies(state.cookies); this.log('info', 'Session check complete', { state: this.authState }); return Result.ok(this.authState); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', 'Session check failed', { error: message }); return Result.err(new Error(`Session check failed: ${message}`)); } } /** * Get the iRacing login URL. * Used by the main process to open in the system's default browser. */ getLoginUrl(): string { return IRACING_URLS.login; } /** * Wait for login success by monitoring the page URL. * Login is successful when: * - URL contains 'members.iracing.com' AND * - URL does NOT contain 'oauth.iracing.com' (login page) * * @param timeoutMs Maximum time to wait for login (default: 5 minutes) * @returns true if login was detected, false if timeout */ private async waitForLoginSuccess(timeoutMs = 300000): Promise { if (!this.page) { return false; } const startTime = Date.now(); this.log('info', 'Waiting for login success', { timeoutMs }); while (Date.now() - startTime < timeoutMs) { try { const url = this.page.url(); // Success: User is on members site (not oauth login page) // Check for various success indicators: // - URL contains members.iracing.com but not oauth.iracing.com // - Or URL is the hosted sessions page const isOnMembersSite = url.includes('members.iracing.com'); const isOnLoginPage = url.includes('oauth.iracing.com') || url.includes('/membersite/login') || url.includes('/login.jsp'); if (isOnMembersSite && !isOnLoginPage) { this.log('info', 'Login success detected', { url }); return true; } // Check if page is closed (user closed the browser) if (this.page.isClosed()) { this.log('warn', 'Browser page was closed by user'); return false; } // Wait before checking again await new Promise(resolve => setTimeout(resolve, 1000)); } catch (error) { // Page might be navigating or closed const message = error instanceof Error ? error.message : String(error); this.log('debug', 'Error checking URL during login wait', { error: message }); // If we can't access the page, it might be closed if (!this.page || this.page.isClosed()) { return false; } await new Promise(resolve => setTimeout(resolve, 500)); } } this.log('warn', 'Login wait timed out'); return false; } /** * Initiate login by opening the Playwright browser to the iRacing login page. * This uses the same browser context that will be used for automation, * ensuring cookies are shared. * * The method will: * 1. Launch browser and navigate to login page * 2. Wait for user to complete login (auto-detect via URL) * 3. Save session state on success * 4. Close browser automatically * * @returns Result with void on success, Error on failure/timeout */ async initiateLogin(): Promise> { try { this.log('info', 'Opening iRacing login in Playwright browser'); // Connect to launch the browser (this uses the persistent context) const connectResult = await this.connect(); if (!connectResult.success) { return Result.err(new Error(connectResult.error || 'Failed to connect browser')); } if (!this.page) { return Result.err(new Error('No page available after connect')); } // Navigate to iRacing login page await this.page.goto(IRACING_URLS.login, { waitUntil: 'domcontentloaded', timeout: IRACING_TIMEOUTS.navigation, }); this.log('info', 'Playwright browser opened to iRacing login page, waiting for login...'); this.authState = AuthenticationState.UNKNOWN; // Wait for login success (auto-detect) const loginSuccess = await this.waitForLoginSuccess(); if (loginSuccess) { // Save session state this.log('info', 'Login detected, saving session state'); await this.saveSessionState(); // Verify cookies were saved correctly const state = await this.cookieStore.read(); if (state && this.cookieStore.validateCookies(state.cookies) === AuthenticationState.AUTHENTICATED) { this.authState = AuthenticationState.AUTHENTICATED; this.log('info', 'Session saved and validated successfully'); } else { this.authState = AuthenticationState.UNKNOWN; this.log('warn', 'Session saved but validation unclear'); } // Close browser this.log('info', 'Closing browser after successful login'); await this.disconnect(); return Result.ok(undefined); } // Login failed or timed out this.log('warn', 'Login was not completed'); await this.disconnect(); return Result.err(new Error('Login timeout - please try again')); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', 'Failed during login process', { error: message }); // Try to clean up try { await this.disconnect(); } catch { // Ignore cleanup errors } return Result.err(error instanceof Error ? error : new Error(message)); } } /** * Called when user confirms they have completed login in the Playwright browser. * Saves the session state to the cookie store for future auth checks. */ async confirmLoginComplete(): Promise> { try { this.log('info', 'User confirmed login complete'); // Save session state to cookie store await this.saveSessionState(); // Verify cookies were saved correctly const state = await this.cookieStore.read(); if (state && this.cookieStore.validateCookies(state.cookies) === AuthenticationState.AUTHENTICATED) { this.authState = AuthenticationState.AUTHENTICATED; this.log('info', 'Login confirmed and session saved successfully'); } else { this.authState = AuthenticationState.UNKNOWN; this.log('warn', 'Login confirmation received but session state unclear'); } return Result.ok(undefined); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', 'Failed to confirm login', { error: message }); return Result.err(error instanceof Error ? error : new Error(message)); } } /** * Save the current browser context's storage state to the cookie store. * This persists cookies for future auth checks without needing to launch the browser. */ async saveSessionState(): Promise { if (!this.persistentContext && !this.context) { this.log('warn', 'No browser context available to save session state'); return; } try { const context = this.persistentContext || this.context; if (!context) { return; } const storageState = await context.storageState(); await this.cookieStore.write(storageState); this.log('info', 'Session state saved to cookie store'); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', 'Failed to save session state', { error: message }); throw error; } } /** * Clear the persistent session by removing stored browser data. */ async clearSession(): Promise> { try { this.log('info', 'Clearing session'); // Delete cookie store file first await this.cookieStore.delete(); this.log('debug', 'Cookie store deleted'); // If we have a persistent context, close it first if (this.persistentContext) { await this.persistentContext.close(); this.persistentContext = null; this.page = null; this.connected = false; } // Delete the user data directory if it exists if (this.config.userDataDir && fs.existsSync(this.config.userDataDir)) { this.log('debug', 'Removing user data directory', { path: this.config.userDataDir }); fs.rmSync(this.config.userDataDir, { recursive: true, force: true }); } this.authState = AuthenticationState.LOGGED_OUT; this.log('info', 'Session cleared successfully'); return Result.ok(undefined); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', 'Failed to clear session', { error: message }); return Result.err(new Error(`Failed to clear session: ${message}`)); } } /** * Get current authentication state (cached, no network request). */ getState(): AuthenticationState { return this.authState; } /** * Validate session with server-side check. * Makes a lightweight HTTP request to verify cookies are still valid on the server. */ async validateServerSide(): Promise> { try { this.log('info', 'Performing server-side session validation'); if (!this.persistentContext && !this.context) { return Result.err(new Error('No browser context available')); } const context = this.persistentContext || this.context; if (!context) { return Result.err(new Error('Browser context is null')); } // Create a temporary page for validation const page = await context.newPage(); try { // Navigate to a protected iRacing page with a short timeout const response = await page.goto(IRACING_URLS.hostedSessions, { waitUntil: 'domcontentloaded', timeout: 10000, }); if (!response) { return Result.ok(false); } // Check if we were redirected to login page const finalUrl = page.url(); const isOnLoginPage = finalUrl.includes('oauth.iracing.com') || finalUrl.includes('/membersite/login') || finalUrl.includes('/login.jsp'); const isValid = !isOnLoginPage; this.log('info', 'Server-side validation complete', { isValid, finalUrl }); return Result.ok(isValid); } finally { await page.close(); } } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('warn', 'Server-side validation failed', { error: message }); return Result.err(new Error(`Server validation failed: ${message}`)); } } /** * Refresh session state from cookie store. * Re-reads cookies and updates internal state without server validation. */ async refreshSession(): Promise> { try { this.log('info', 'Refreshing session from cookie store'); const state = await this.cookieStore.read(); if (!state) { this.authState = AuthenticationState.UNKNOWN; return Result.ok(undefined); } this.authState = this.cookieStore.validateCookies(state.cookies); this.log('info', 'Session refreshed', { state: this.authState }); return Result.ok(undefined); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', 'Session refresh failed', { error: message }); return Result.err(new Error(`Session refresh failed: ${message}`)); } } /** * Get session expiry date from cookie store. */ async getSessionExpiry(): Promise> { try { const expiry = await this.cookieStore.getSessionExpiry(); return Result.ok(expiry); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', 'Failed to get session expiry', { error: message }); return Result.err(new Error(`Failed to get session expiry: ${message}`)); } } /** * Get the user data directory path for persistent sessions. */ getUserDataDir(): string { return this.config.userDataDir; } /** * Get the browser mode (headed or headless). */ getBrowserMode(): BrowserMode { return this.actualBrowserMode; } /** * Get the source of the browser mode configuration. */ getBrowserModeSource(): 'env' | 'file' | 'default' { return this.browserModeSource; } /** * Set the checkout confirmation callback. * This callback is invoked during step 17 before clicking the checkout button, * allowing the UI to request user confirmation with the extracted price and state. * * @param callback Function that receives price and state, returns confirmation decision */ setCheckoutConfirmationCallback( callback?: (price: CheckoutPrice, state: CheckoutState) => Promise ): void { this.checkoutConfirmationCallback = callback; } // ===== Overlay Methods ===== /** * Inject the automation overlay into the current page. * The overlay shows the current step, progress, and personality messages. * Safe to call multiple times - will only inject once per page. */ async injectOverlay(): Promise { if (!this.page || this.overlayInjected) { return; } try { this.log('info', 'Injecting automation overlay'); // Inject CSS await this.page.addStyleTag({ content: OVERLAY_CSS }); // Inject HTML await this.page.evaluate((html) => { const existing = document.getElementById('gridpilot-overlay'); if (existing) { existing.remove(); } const container = document.createElement('div'); container.innerHTML = html; const overlay = container.firstElementChild; if (overlay) { document.body.appendChild(overlay); } }, OVERLAY_HTML); this.overlayInjected = true; this.log('info', 'Automation overlay injected successfully'); // Setup close listeners (ESC key, modal dismissal) await this.setupCloseListeners(); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('warn', 'Failed to inject overlay', { error: message }); } } /** * Update the overlay with current step information and progress. * @param step Current step number (1-18) * @param customMessage Optional custom action message */ async updateOverlay(step: number, customMessage?: string): Promise { if (!this.page) { return; } try { // Check if overlay actually exists in DOM (handles SPA navigation) const overlayExists = await this.page.locator('#gridpilot-overlay').count() > 0; if (!overlayExists) { this.overlayInjected = false; // Reset flag if overlay gone } // Ensure overlay is injected if (!this.overlayInjected) { await this.injectOverlay(); } const actionMessage = customMessage || OVERLAY_STEP_MESSAGES[step] || `Processing step ${step}...`; const progress = Math.round((step / this.totalSteps) * 100); const personality = OVERLAY_PERSONALITY_MESSAGES[Math.floor(Math.random() * OVERLAY_PERSONALITY_MESSAGES.length)]; await this.page.evaluate(({ actionMsg, progressPct, stepNum, totalSteps, personalityMsg }) => { const actionEl = document.getElementById('gridpilot-action'); const progressEl = document.getElementById('gridpilot-progress'); const stepTextEl = document.getElementById('gridpilot-step-text'); const stepCountEl = document.getElementById('gridpilot-step-count'); const personalityEl = document.getElementById('gridpilot-personality'); if (actionEl) actionEl.textContent = actionMsg; if (progressEl) progressEl.style.width = `${progressPct}%`; if (stepTextEl) stepTextEl.textContent = actionMsg; if (stepCountEl) stepCountEl.textContent = `Step ${stepNum} of ${totalSteps}`; if (personalityEl) personalityEl.textContent = personalityMsg; }, { actionMsg: actionMessage, progressPct: progress, stepNum: step, totalSteps: this.totalSteps, personalityMsg: personality }); this.log('debug', 'Overlay updated', { step, progress, actionMessage }); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('debug', 'Failed to update overlay', { error: message }); } } /** * Show a completion message on the overlay. * @param success Whether the automation completed successfully * @param message Optional custom completion message */ async showOverlayComplete(success: boolean, message?: string): Promise { if (!this.page) { return; } // Ensure overlay is injected before trying to update it if (!this.overlayInjected) { await this.injectOverlay(); } try { const actionMessage = message || (success ? '✅ Setup complete! Review settings and click "Create Race" to confirm.' : '❌ Setup encountered an issue'); const emoji = success ? '🏆' : '⚠️'; const personality = success ? '👆 Check everything looks right, then create your race!' : '🔧 Check the error and try again.'; await this.page.evaluate(({ actionMsg, emoji, personalityMsg, success }) => { const actionEl = document.getElementById('gridpilot-action'); const progressEl = document.getElementById('gridpilot-progress'); const stepCountEl = document.getElementById('gridpilot-step-count'); const personalityEl = document.getElementById('gridpilot-personality'); const spinnerEl = document.querySelector('.gridpilot-spinner') as HTMLElement; const logoEl = document.querySelector('.gridpilot-logo') as HTMLElement; if (actionEl) actionEl.textContent = actionMsg; if (progressEl) progressEl.style.width = success ? '100%' : progressEl.style.width; if (stepCountEl) stepCountEl.textContent = success ? 'Complete!' : 'Stopped'; if (personalityEl) personalityEl.textContent = personalityMsg; if (spinnerEl) spinnerEl.style.display = 'none'; if (logoEl) logoEl.textContent = emoji; }, { actionMsg: actionMessage, emoji, personalityMsg: personality, success }); this.log('info', 'Overlay completion shown', { success, message: actionMessage }); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('debug', 'Failed to show overlay completion', { error: message }); } } /** * Remove the overlay from the page. * Called when automation is complete or cancelled. */ async removeOverlay(): Promise { if (!this.page || !this.overlayInjected) { return; } try { await this.page.evaluate(() => { const overlay = document.getElementById('gridpilot-overlay'); if (overlay) { overlay.style.animation = 'none'; overlay.style.transition = 'transform 0.3s ease-in, opacity 0.3s ease-in'; overlay.style.transform = 'translateX(100%)'; overlay.style.opacity = '0'; setTimeout(() => overlay.remove(), 300); } }); this.overlayInjected = false; this.log('info', 'Automation overlay removed'); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('debug', 'Failed to remove overlay', { error: message }); } } /** * Reset overlay state when navigating to a new page. * Should be called after page navigation to ensure overlay can be re-injected. */ resetOverlayState(): void { this.overlayInjected = false; } // ===== Pause Functionality ===== /** * Check if automation is currently paused in the browser. * Reads the window.__gridpilot_paused variable set by the overlay button. * @returns true if paused, false otherwise */ private async isPausedInBrowser(): Promise { if (!this.page) { return false; } try { return await this.page.evaluate(() => (window as unknown as { __gridpilot_paused?: boolean }).__gridpilot_paused === true); } catch { // If we can't check (page navigating, etc.), assume not paused return false; } } /** * Wait while automation is paused. * Polls the browser pause state and blocks until unpaused. * Called at the start of each step to allow user to pause between steps. */ private async waitIfPaused(): Promise { if (!this.page) { return; } let wasPaused = false; while (await this.isPausedInBrowser()) { if (!wasPaused) { this.log('info', 'Automation paused by user, waiting for resume...'); wasPaused = true; } await this.page.waitForTimeout(PlaywrightAutomationAdapter.PAUSE_CHECK_INTERVAL); } if (wasPaused) { this.log('info', 'Automation resumed by user'); } } // ===== Browser Close Control ===== /** * Check if the user has requested to close the browser. * This is set by the close button in the overlay, ESC key press, * or when the race creation modal is dismissed. * @returns true if close was requested, false otherwise */ private async isCloseRequested(): Promise { if (!this.page) { return false; } try { return await this.page.evaluate(() => (window as unknown as { __gridpilot_close_requested?: boolean }).__gridpilot_close_requested === true ); } catch { // If we can't check (page navigating, etc.), assume not requested return false; } } /** * Check if the race creation wizard modal has been closed by the user. * Monitors for the modal being dismissed (user clicked away or closed it). * * IMPORTANT: During step transitions, React/Bootstrap may temporarily remove the 'in' class * from the modal while updating content. To avoid false positives, this method: * 1. Checks if ANY wizard step container is visible (means wizard is still active) * 2. If not, waits 1000ms and checks again to confirm dismissal vs transition * * @returns true if the wizard modal is no longer visible (and was expected to be) */ private async isWizardModalDismissed(): Promise { if (!this.page || !this.isRealMode()) { return false; } try { // First check: Is ANY wizard step container attached to DOM? // If yes, the wizard is still active (matches waitForWizardStep() criteria) const stepContainerSelectors = Object.values(IRACING_SELECTORS.wizard.stepContainers); for (const containerSelector of stepContainerSelectors) { const count = await this.page.locator(containerSelector).count(); if (count > 0) { this.log('debug', 'Wizard step container attached, wizard is active', { containerSelector }); return false; } } // No step containers attached - wizard was likely dismissed // Check if modal element exists at all (with or without 'in' class) const modalSelector = '#create-race-modal, [role="dialog"], .modal.fade'; const modalExists = await this.page.locator(modalSelector).count() > 0; if (!modalExists) { this.log('debug', 'No wizard modal element found - dismissed'); return true; } // Modal exists but no step containers - could be transitioning // Wait 1000ms and check again to confirm this.log('debug', 'Wizard step containers not attached, waiting 1000ms to confirm dismissal vs transition'); await this.page.waitForTimeout(1000); // Check step containers again after delay for (const containerSelector of stepContainerSelectors) { const count = await this.page.locator(containerSelector).count(); if (count > 0) { this.log('debug', 'Wizard step container attached after delay - was just transitioning', { containerSelector }); return false; } } // Still no step containers after delay - confirmed dismissed this.log('info', 'No wizard step containers attached after delay - confirmed dismissed by user'); return true; } catch { return false; } } /** * Check for browser close conditions and close if triggered. * Called during automation to check for user-initiated close actions. * @throws Error with 'USER_CLOSE_REQUESTED' message if close was triggered */ async checkAndHandleClose(): Promise { if (!this.page) { return; } // Check for close button click or ESC key if (await this.isCloseRequested()) { this.log('info', 'Browser close requested by user (close button or ESC key)'); await this.closeBrowserContext(); throw new Error('USER_CLOSE_REQUESTED: Browser closed by user request'); } } /** * Check if the wizard modal was dismissed and close browser if so. * This should be called during automation steps to detect if user * navigated away from the wizard. * @param currentStep Current step number to determine if modal should be visible * @throws Error with 'WIZARD_DISMISSED' message if modal was closed by user */ async checkWizardDismissed(currentStep: number): Promise { if (!this.page || !this.isRealMode() || currentStep < 3) { // Don't check before step 3 (modal opens at step 2) return; } if (await this.isWizardModalDismissed()) { this.log('info', 'Race creation wizard was dismissed by user'); await this.closeBrowserContext(); throw new Error('WIZARD_DISMISSED: User closed the race creation wizard'); } } /** * Close the browser context completely. * This is the nuclear option - closes everything to prevent * the user from using the automation browser as a normal browser. */ async closeBrowserContext(): Promise { this.log('info', 'Closing browser context'); try { // Remove overlay first (graceful exit) await this.removeOverlay().catch(() => {}); // Close the persistent context if it exists if (this.persistentContext) { await this.persistentContext.close(); this.persistentContext = null; this.page = null; this.connected = false; this.log('info', 'Persistent context closed'); return; } // Close non-persistent context if (this.context) { await this.context.close(); this.context = null; this.page = null; } // Close browser 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 }); // Force cleanup this.persistentContext = null; this.context = null; this.browser = null; this.page = null; this.connected = false; } } /** * Handle checkout confirmation flow in step 17. * Extracts checkout info, shows overlay, requests confirmation via callback, * and clicks checkout button only if confirmed. * * @throws Error if confirmation is cancelled or times out */ private async handleCheckoutConfirmation(): Promise { if (!this.page) { throw new Error('Browser not connected'); } this.log('info', 'Starting checkout confirmation flow'); try { // Import CheckoutPriceExtractor dynamically to avoid circular dependencies const { CheckoutPriceExtractor } = await import('./CheckoutPriceExtractor'); // Extract checkout info using existing extractor const extractor = new CheckoutPriceExtractor(this.page); const extractResult = await extractor.extractCheckoutInfo(); if (extractResult.isErr()) { throw new Error(`Failed to extract checkout info: ${extractResult.error.message}`); } const checkoutInfo = extractResult.unwrap(); if (!checkoutInfo.price) { throw new Error('No checkout price found'); } // Show overlay: "Awaiting confirmation..." await this.updateOverlay(17, '⏳ Awaiting confirmation...'); this.log('info', 'Requesting checkout confirmation', { price: checkoutInfo.price.toDisplayString(), ready: checkoutInfo.state.isReady() }); // Call the confirmation callback const confirmation = await this.checkoutConfirmationCallback!( checkoutInfo.price, checkoutInfo.state ); this.log('info', 'Received confirmation decision', { decision: confirmation.value }); // Handle confirmation decision if (confirmation.isCancelled()) { throw new Error('Checkout cancelled by user'); } if (confirmation.isTimeout()) { throw new Error('Checkout confirmation timeout'); } if (!confirmation.isConfirmed()) { throw new Error(`Unexpected confirmation decision: ${confirmation.value}`); } // Confirmed - click the checkout button this.log('info', 'Confirmation received, clicking checkout button'); // Try multiple selectors/fallbacks to locate the checkout button reliably across fixtures const candidateSelectors = [ '.wizard-footer a.btn:has(span.label-pill)', '.modal-footer a.btn:has(span.label-pill)', 'a.btn:has(span.label-pill)', '.wizard-footer a:has(span.label-pill)', '.modal-footer a:has(span.label-pill)', 'a:has(span.label-pill)' ]; let clicked = false; for (const sel of candidateSelectors) { try { const count = await this.page!.locator(sel).first().count().catch(() => 0); if (count > 0) { this.log('debug', 'Found checkout button selector', { selector: sel }); await this.safeClick(sel, { timeout: this.config.timeout }); clicked = true; break; } } catch (e) { // continue to next candidate } } // Last-resort: attempt to find the pill and click its ancestor if (!clicked) { try { const pill = this.page!.locator('span.label-pill').first(); if (await pill.count() > 0) { const ancestor = pill.locator('xpath=ancestor::a[1]'); if (await ancestor.count() > 0) { this.log('debug', 'Clicking checkout button via pill ancestor element'); // Use evaluate to click the element directly if safeClick by selector isn't possible await ancestor.first().click({ timeout: this.config.timeout }); clicked = true; } } } catch (e) { // ignore and let the error be handled below } } if (!clicked) { throw new Error('Could not locate checkout button to click'); } // Show success overlay await this.updateOverlay(17, '✅ Checkout confirmed! Race creation in progress...'); this.log('info', 'Checkout button clicked successfully'); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', 'Checkout confirmation flow failed', { error: message }); throw error; } } /** * Setup browser close event listeners. * Injects ESC key listener and modal visibility monitoring into the page. * Should be called after overlay injection. */ async setupCloseListeners(): Promise { if (!this.page) { return; } try { this.log('info', 'Setting up browser close listeners'); // Inject ESC key listener and modal visibility observer await this.page.evaluate(() => { // Skip if already setup if ((window as unknown as { __gridpilot_listeners_setup?: boolean }).__gridpilot_listeners_setup) { return; } // ESC key listener - close browser on ESC press document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { console.log('[GridPilot] ESC key pressed, requesting close'); (window as unknown as { __gridpilot_close_requested?: boolean }).__gridpilot_close_requested = true; } }); // Modal visibility observer - detect when wizard modal is closed // Look for Bootstrap modal backdrop disappearing or modal being hidden const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { // Check for removed nodes that might be the modal for (const node of Array.from(mutation.removedNodes)) { if (node instanceof HTMLElement) { // Modal backdrop removed if (node.classList.contains('modal-backdrop')) { console.log('[GridPilot] Modal backdrop removed, checking if wizard dismissed'); // Small delay to allow for legitimate modal transitions setTimeout(() => { const wizardModal = document.querySelector('.modal.fade.in, .modal.show'); if (!wizardModal) { console.log('[GridPilot] Wizard modal no longer visible, requesting close'); (window as unknown as { __gridpilot_close_requested?: boolean }).__gridpilot_close_requested = true; } }, 500); } } } // Check for class changes on modals (modal hiding) if (mutation.type === 'attributes' && mutation.attributeName === 'class') { const target = mutation.target as HTMLElement; if (target.classList.contains('modal') && !target.classList.contains('in') && !target.classList.contains('show')) { // Modal is being hidden - check if it's the wizard const isWizardModal = target.querySelector('.wizard-footer') !== null || target.id === 'create-hosted-race-modal'; if (isWizardModal) { console.log('[GridPilot] Wizard modal hidden, requesting close'); (window as unknown as { __gridpilot_close_requested?: boolean }).__gridpilot_close_requested = true; } } } } }); // Start observing observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] }); // Mark as setup (window as unknown as { __gridpilot_listeners_setup?: boolean }).__gridpilot_listeners_setup = true; console.log('[GridPilot] Close listeners setup complete'); }); this.log('info', 'Browser close listeners setup successfully'); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('warn', 'Failed to setup close listeners', { error: message }); } } }