306 lines
9.2 KiB
TypeScript
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');
|
|
}
|
|
}
|
|
} |