refactoring
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user