Files
gridpilot.gg/packages/infrastructure/adapters/automation/dom/IRacingDomNavigator.ts
2025-11-30 02:07:08 +01:00

306 lines
9.2 KiB
TypeScript

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<number, string> = {
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<PlaywrightConfig>,
private readonly browserSession: PlaywrightBrowserSession,
private readonly logger?: ILogger,
private readonly onWizardDismissed?: () => Promise<void>,
) {}
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
if (!this.logger) {
return;
}
const logger: any = this.logger;
logger[level](message, context as any);
}
private isRealMode(): boolean {
return this.config.mode === 'real';
}
private getPage(): Page | null {
return this.browserSession.getPage();
}
async navigateToPage(url: string): Promise<NavigationResult> {
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<WaitResult> {
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<void> {
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<void> {
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<void> {
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<string | null> {
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<number | null> {
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<boolean> {
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<void> {
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');
}
}
}