import type { Page } from 'playwright'; import type { ILogger } from '../../../../application/ports/ILogger'; import type { NavigationResult, WaitResult } from '../../../../application/ports/AutomationResults'; import type { PlaywrightConfig } from '../core/PlaywrightAutomationAdapter'; import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession'; import { IRACING_SELECTORS, IRACING_TIMEOUTS, IRACING_URLS } from './IRacingSelectors'; export class IRacingDomNavigator { 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', }; constructor( private readonly config: Required, private readonly browserSession: PlaywrightBrowserSession, private readonly logger?: ILogger, private readonly onWizardDismissed?: () => Promise, ) {} private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record): void { if (!this.logger) { return; } const logger: any = this.logger; logger[level](message, context as any); } private isRealMode(): boolean { return this.config.mode === 'real'; } private getPage(): Page | null { return this.browserSession.getPage(); } async navigateToPage(url: string): Promise { const page = this.getPage(); if (!page) { return { success: false, url, loadTime: 0, error: 'Browser not connected' }; } const startTime = Date.now(); 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 page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout }); const loadTime = Date.now() - startTime; if (!this.isRealMode()) { const stepMatch = url.match(/step-(\d+)-/); if (stepMatch) { const stepNumber = parseInt(stepMatch[1], 10); await page.evaluate((step) => { document.body.setAttribute('data-step', String(step)); }, stepNumber); } } return { success: true, url: targetUrl, loadTime }; } catch (error) { const message = error instanceof Error ? error.message : String(error); const loadTime = Date.now() - startTime; return { success: false, url, loadTime, error: message }; } } async waitForElement(target: string, maxWaitMs?: number): Promise { const page = this.getPage(); if (!page) { return { success: false, target, waitedMs: 0, found: 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 { selector = IRACING_SELECTORS.wizard.modal; } this.log('debug', 'Waiting for element', { target, selector, mode: this.config.mode }); await page.waitForSelector(selector, { state: 'attached', timeout: maxWaitMs ?? defaultTimeout, }); return { success: true, target, waitedMs: Date.now() - startTime, found: true }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { success: false, target, waitedMs: Date.now() - startTime, found: false, error: message, }; } } async waitForModal(): Promise { const page = this.getPage(); if (!page) { throw new Error('Browser not connected'); } const selector = IRACING_SELECTORS.wizard.modal; const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; await page.waitForSelector(selector, { state: 'attached', timeout, }); } async waitForStep(stepNumber: number): Promise { const page = this.getPage(); if (!page) { throw new Error('Browser not connected'); } if (!this.isRealMode()) { await page.evaluate((step) => { document.body.setAttribute('data-step', String(step)); }, stepNumber); } const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; await page.waitForSelector(`[data-step="${stepNumber}"]`, { state: 'attached', timeout, }); } async waitForWizardStep(stepName: keyof typeof IRACING_SELECTORS.wizard.stepContainers): Promise { const page = this.getPage(); if (!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 }); await page.waitForSelector(containerSelector, { state: 'attached', timeout: 15000, }); await page.waitForTimeout(100); } catch (error) { this.log('warn', `Wizard step not attached: ${stepName}`, { error: String(error) }); } } async detectCurrentWizardPage(): Promise { const page = this.getPage(); if (!page) { return null; } try { const containers = IRACING_SELECTORS.wizard.stepContainers; for (const [pageName, selector] of Object.entries(containers)) { const count = await page.locator(selector).count(); if (count > 0) { this.log('debug', 'Detected wizard page', { pageName, selector }); return pageName; } } this.log('debug', 'No wizard page detected'); return null; } catch (error) { this.log('debug', 'Error detecting wizard page', { error: String(error) }); return null; } } synchronizeStepCounter(expectedStep: number, actualPage: string | null): number { if (!actualPage) { return 0; } let actualStep: number | null = null; for (const [step, pageName] of Object.entries(IRacingDomNavigator.STEP_TO_PAGE_MAP)) { if (pageName === actualPage) { actualStep = parseInt(step, 10); break; } } if (actualStep === null) { return 0; } const skipOffset = actualStep - expectedStep; if (skipOffset > 0) { 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; } return 0; } async getCurrentStep(): Promise { const page = this.getPage(); if (!page) { return null; } if (this.isRealMode()) { return null; } const stepAttr = await page.getAttribute('body', 'data-step'); return stepAttr ? parseInt(stepAttr, 10) : null; } private async isWizardModalDismissedInternal(): Promise { const page = this.getPage(); if (!page || !this.isRealMode()) { return false; } try { const stepContainerSelectors = Object.values(IRACING_SELECTORS.wizard.stepContainers); for (const containerSelector of stepContainerSelectors) { const count = await page.locator(containerSelector).count(); if (count > 0) { this.log('debug', 'Wizard step container attached, wizard is active', { containerSelector }); return false; } } const modalSelector = '#create-race-modal, [role="dialog"], .modal.fade'; const modalExists = (await page.locator(modalSelector).count()) > 0; if (!modalExists) { this.log('debug', 'No wizard modal element found - dismissed'); return true; } this.log('debug', 'Wizard step containers not attached, waiting 1000ms to confirm dismissal vs transition'); await page.waitForTimeout(1000); for (const containerSelector of stepContainerSelectors) { const count = await page.locator(containerSelector).count(); if (count > 0) { this.log('debug', 'Wizard step container attached after delay - was just transitioning', { containerSelector, }); return false; } } this.log('info', 'No wizard step containers attached after delay - confirmed dismissed by user'); return true; } catch { return false; } } async checkWizardDismissed(currentStep: number): Promise { if (!this.isRealMode() || currentStep < 3) { return; } if (await this.isWizardModalDismissedInternal()) { this.log('info', 'Race creation wizard was dismissed by user'); if (this.onWizardDismissed) { await this.onWizardDismissed().catch(() => {}); } throw new Error('WIZARD_DISMISSED: User closed the race creation wizard'); } } }