refactoring
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Selectors for the real iRacing website (members.iracing.com)
|
||||
* Uses text-based and ARIA selectors since the site uses React/Chakra UI
|
||||
* with dynamically generated class names.
|
||||
*
|
||||
* VERIFIED against html-dumps-optimized 2025-11-27
|
||||
*/
|
||||
export const IRACING_SELECTORS = {
|
||||
// Login page
|
||||
login: {
|
||||
emailInput: '#username, input[name="username"], input[type="email"]',
|
||||
passwordInput: '#password, input[type="password"]',
|
||||
submitButton: 'button[type="submit"], button:has-text("Sign In")',
|
||||
},
|
||||
|
||||
// Hosted Racing page (Step 2)
|
||||
hostedRacing: {
|
||||
// Main "Create a Race" button on the hosted sessions page
|
||||
createRaceButton: 'button:has-text("Create a Race"), button[aria-label="Create a Race"]',
|
||||
hostedTab: 'a:has-text("Hosted")',
|
||||
// Modal that appears after clicking "Create a Race"
|
||||
createRaceModal: '#modal-children-container, .modal-content',
|
||||
// "New Race" button in the modal body (not footer) - two side-by-side buttons in a row
|
||||
newRaceButton: 'a.btn:has-text("New Race")',
|
||||
lastSettingsButton: 'a.btn:has-text("Last Settings")',
|
||||
},
|
||||
|
||||
// Common modal/wizard selectors - VERIFIED from real HTML
|
||||
wizard: {
|
||||
modal: '#create-race-modal, .modal.fade.in',
|
||||
modalDialog: '#create-race-modal-modal-dialog, .modal-dialog',
|
||||
modalContent: '#create-race-modal-modal-content, .modal-content',
|
||||
modalTitle: '[data-testid="modal-title"]',
|
||||
// Wizard footer buttons - CORRECTED: The footer contains navigation buttons and dropup menus
|
||||
// The main navigation is via the sidebar links, footer has Back/Next style buttons
|
||||
// Based on dumps, footer has .btn-group with buttons for navigation
|
||||
nextButton: '.modal-footer .btn-toolbar a.btn:not(.dropdown-toggle), .modal-footer .btn-group a.btn:last-child',
|
||||
backButton: '.modal-footer .btn-group a.btn:first-child',
|
||||
// Modal footer actions
|
||||
confirmButton: '.modal-footer a.btn-success, .modal-footer button:has-text("Confirm"), button:has-text("OK")',
|
||||
cancelButton: '.modal-footer a.btn-secondary, button:has-text("Cancel")',
|
||||
closeButton: '[data-testid="button-close-modal"]',
|
||||
// Wizard sidebar navigation links - VERIFIED from dumps
|
||||
sidebarLinks: {
|
||||
raceInformation: '[data-testid="wizard-nav-set-session-information"]',
|
||||
serverDetails: '[data-testid="wizard-nav-set-server-details"]',
|
||||
admins: '[data-testid="wizard-nav-set-admins"]',
|
||||
timeLimit: '[data-testid="wizard-nav-set-time-limit"]',
|
||||
cars: '[data-testid="wizard-nav-set-cars"]',
|
||||
track: '[data-testid="wizard-nav-set-track"]',
|
||||
trackOptions: '[data-testid="wizard-nav-set-track-options"]',
|
||||
timeOfDay: '[data-testid="wizard-nav-set-time-of-day"]',
|
||||
weather: '[data-testid="wizard-nav-set-weather"]',
|
||||
raceOptions: '[data-testid="wizard-nav-set-race-options"]',
|
||||
trackConditions: '[data-testid="wizard-nav-set-track-conditions"]',
|
||||
},
|
||||
// Wizard step containers (the visible step content)
|
||||
stepContainers: {
|
||||
raceInformation: '#set-session-information',
|
||||
serverDetails: '#set-server-details',
|
||||
admins: '#set-admins',
|
||||
timeLimit: '#set-time-limit',
|
||||
cars: '#set-cars',
|
||||
track: '#set-track',
|
||||
trackOptions: '#set-track-options',
|
||||
timeOfDay: '#set-time-of-day',
|
||||
weather: '#set-weather',
|
||||
raceOptions: '#set-race-options',
|
||||
trackConditions: '#set-track-conditions',
|
||||
},
|
||||
},
|
||||
|
||||
// Form fields - based on actual iRacing DOM structure
|
||||
fields: {
|
||||
textInput: '.chakra-input, input.form-control, input[type="text"], input[data-field], input[data-test], input[placeholder]',
|
||||
passwordInput: 'input[type="password"], input[maxlength="32"].form-control, input[data-field="password"], input[name="password"]',
|
||||
textarea: 'textarea.form-control, .chakra-textarea, textarea, textarea[data-field]',
|
||||
select: '.chakra-select, select.form-control, select, [data-dropdown], select[data-field]',
|
||||
checkbox: '.chakra-checkbox, input[type="checkbox"], .switch-checkbox, input[data-toggle], [data-toggle]',
|
||||
slider: '.chakra-slider, .slider, input[type="range"]',
|
||||
toggle: '.switch input.switch-checkbox, .toggle-switch input, input[data-toggle]',
|
||||
},
|
||||
|
||||
// Step-specific selectors - VERIFIED from real iRacing HTML structure
|
||||
steps: {
|
||||
// Step 3: Race Information - CORRECTED based on actual HTML structure
|
||||
// Session name is a text input in a form-group with label "Session Name"
|
||||
sessionName: '#set-session-information input.form-control[type="text"]:not([maxlength])',
|
||||
sessionNameAlt: 'input[name="sessionName"], input.form-control[type="text"]',
|
||||
// Password field has maxlength="32" and is a text input (not type="password")
|
||||
password: '#set-session-information input.form-control[maxlength="32"]',
|
||||
passwordAlt: 'input[maxlength="32"][type="text"]',
|
||||
// Description is a textarea in the form
|
||||
description: '#set-session-information textarea.form-control',
|
||||
descriptionAlt: 'textarea.form-control',
|
||||
// League racing toggle in Step 3
|
||||
leagueRacingToggle: '#set-session-information .switch-checkbox, [data-toggle="leagueRacing"]',
|
||||
|
||||
// Step 4: Server Details
|
||||
region: '#set-server-details select.form-control, #set-server-details [data-dropdown="region"], #set-server-details [data-dropdown], [data-dropdown="region"]',
|
||||
startNow: '#set-server-details .switch-checkbox, #set-server-details input[type="checkbox"], [data-toggle="startNow"], input[data-toggle="startNow"]',
|
||||
|
||||
// Step 5/6: Admins
|
||||
adminSearch: 'input[placeholder*="Search"]',
|
||||
adminList: '#set-admins table.table.table-striped, #set-admins .card-block table',
|
||||
addAdminButton: 'a.btn:has-text("Add an Admin")',
|
||||
|
||||
// Step 7: Time Limits - Bootstrap-slider uses hidden input[type="text"] with id containing slider name
|
||||
// Also targets the visible slider handle for interaction
|
||||
// Dumps show dynamic IDs like time-limit-slider1763726367635
|
||||
practice: 'label:has-text("Practice") ~ div input[id*="time-limit-slider"]',
|
||||
qualify: 'label:has-text("Qualify") ~ div input[id*="time-limit-slider"]',
|
||||
race: 'label:has-text("Race") ~ div input[id*="time-limit-slider"]',
|
||||
|
||||
// Step 8/9: Cars
|
||||
carSearch: 'input[placeholder*="Search"]',
|
||||
carList: 'table.table.table-striped',
|
||||
// Add Car button - CORRECTED: Uses specific class and text
|
||||
addCarButton: 'a.btn.btn-primary.btn-block.btn-sm:has-text("Add a Car")',
|
||||
// Car selection interface - drawer that opens within the wizard sidebar
|
||||
addCarModal: '.drawer-container .drawer',
|
||||
// Select button inside car dropdown - opens config selection
|
||||
carSelectButton: 'a.btn.btn-primary.btn-xs.dropdown-toggle:has-text("Select")',
|
||||
|
||||
// Step 10/11/12: Track
|
||||
trackSearch: 'input[placeholder*="Search"]',
|
||||
trackList: 'table.table.table-striped',
|
||||
// Add Track button - CORRECTED: Uses specific class and text
|
||||
addTrackButton: 'a.btn.btn-primary.btn-block.btn-sm:has-text("Add a Track")',
|
||||
// Track selection interface - drawer that opens within the card
|
||||
addTrackModal: '.drawer-container .drawer',
|
||||
// Select button inside track dropdown - opens config selection
|
||||
trackSelectButton: 'a.btn.btn-primary.btn-xs.dropdown-toggle:has-text("Select")',
|
||||
// Dropdown toggle for multi-config tracks - opens a menu of track configurations
|
||||
trackSelectDropdown: 'a.btn.btn-primary.btn-xs.dropdown-toggle',
|
||||
// First item in the dropdown menu for selecting track configuration
|
||||
trackSelectDropdownItem: '.dropdown-menu.dropdown-menu-right .dropdown-item:first-child',
|
||||
|
||||
// Step 13: Track Options
|
||||
trackConfig: '#set-track-options select.form-control, #set-track-options [data-dropdown="trackConfig"]',
|
||||
|
||||
// Step 14: Time of Day - iRacing uses datetime picker (rdt class) and Bootstrap-slider components
|
||||
// The datetime picker has input.form-control, sliders have hidden input[type="text"]
|
||||
timeOfDay: '#set-time-of-day .rdt input.form-control, #set-time-of-day input[id*="slider"], #set-time-of-day .slider input[type="text"], #set-time-of-day [data-slider="timeOfDay"]',
|
||||
|
||||
// Step 15: Weather
|
||||
weatherType: '#set-weather select.form-control, #set-weather [data-dropdown="weatherType"]',
|
||||
// Temperature slider uses Bootstrap-slider with hidden input[type="text"]
|
||||
temperature: '#set-weather input[id*="slider"], #set-weather .slider input[type="text"], #set-weather [data-slider="temperature"]',
|
||||
|
||||
// Step 16: Race Options
|
||||
maxDrivers: '#set-race-options input[name*="maxDrivers"], #set-race-options input[type="number"]',
|
||||
rollingStart: '#set-race-options .switch-checkbox[name*="rolling"], #set-race-options input[type="checkbox"]',
|
||||
|
||||
// Step 17: Track Conditions (final step)
|
||||
trackState: '#set-track-conditions select.form-control, #set-track-conditions [data-dropdown="trackState"]',
|
||||
},
|
||||
|
||||
/**
|
||||
* DANGER ZONE - Selectors for checkout/payment buttons that should NEVER be clicked.
|
||||
* The automation must block any click on these selectors to prevent accidental purchases.
|
||||
* VERIFIED from real iRacing HTML - the checkout button has class btn-success with icon-cart
|
||||
*/
|
||||
BLOCKED_SELECTORS: {
|
||||
// Checkout/payment buttons - NEVER click these (verified from real HTML)
|
||||
checkout: '.chakra-button:has-text("Check Out"), a.btn-success:has(.icon-cart), a.btn:has-text("Check Out"), button:has-text("Check Out"), [data-testid*="checkout"]',
|
||||
purchase: 'button:has-text("Purchase"), a.btn:has-text("Purchase"), .chakra-button:has-text("Purchase"), button[aria-label="Purchase"]',
|
||||
buy: 'button:has-text("Buy"), a.btn:has-text("Buy Now"), button:has-text("Buy Now")',
|
||||
payment: 'button[type="submit"]:has-text("Submit Payment"), .payment-button, #checkout-button, button:has-text("Pay"), a.btn:has-text("Pay")',
|
||||
cart: 'a.btn:has(.icon-cart), button:has(.icon-cart), .btn-success:has(.icon-cart)',
|
||||
// Price labels that indicate purchase actions (e.g., "$0.50")
|
||||
priceAction: 'a.btn:has(.label-pill:has-text("$")), button:has(.label-pill:has-text("$")), .btn:has(.label-inverse:has-text("$"))',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Combined selector for all blocked/dangerous elements.
|
||||
* Use this to check if any selector targets a payment button.
|
||||
*/
|
||||
export const ALL_BLOCKED_SELECTORS = Object.values(IRACING_SELECTORS.BLOCKED_SELECTORS).join(', ');
|
||||
|
||||
/**
|
||||
* Keywords that indicate a dangerous/checkout action.
|
||||
* Used for text-based safety checks.
|
||||
*/
|
||||
export const BLOCKED_KEYWORDS = [
|
||||
'checkout',
|
||||
'check out',
|
||||
'purchase',
|
||||
'buy now',
|
||||
'buy',
|
||||
'pay',
|
||||
'submit payment',
|
||||
'add to cart',
|
||||
'proceed to payment',
|
||||
] as const;
|
||||
|
||||
export const IRACING_URLS = {
|
||||
hostedSessions: 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
|
||||
login: 'https://members.iracing.com/membersite/login.jsp',
|
||||
home: 'https://members.iracing.com',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Timeout values for real iRacing automation (in milliseconds)
|
||||
*/
|
||||
export const IRACING_TIMEOUTS = {
|
||||
navigation: 30000,
|
||||
elementWait: 15000,
|
||||
loginWait: 120000, // 2 minutes for manual login
|
||||
pageLoad: 20000,
|
||||
} as const;
|
||||
@@ -0,0 +1,431 @@
|
||||
import type { Page } from 'playwright';
|
||||
import type { ILogger } from '../../../../application/ports/ILogger';
|
||||
import { IRACING_SELECTORS, BLOCKED_KEYWORDS } from './IRacingSelectors';
|
||||
import type { PlaywrightConfig } from '../core/PlaywrightAutomationAdapter';
|
||||
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
|
||||
|
||||
export class SafeClickService {
|
||||
constructor(
|
||||
private readonly config: Required<PlaywrightConfig>,
|
||||
private readonly browserSession: PlaywrightBrowserSession,
|
||||
private readonly logger?: ILogger,
|
||||
) {}
|
||||
|
||||
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 {
|
||||
const page = this.browserSession.getPage();
|
||||
if (!page) {
|
||||
throw new Error('Browser not connected');
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async verifyNotBlockedElement(selector: string): Promise<void> {
|
||||
const page = this.browserSession.getPage();
|
||||
if (!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 = 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) => {
|
||||
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) {
|
||||
const innerText = await element.innerText().catch(() => '');
|
||||
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 instanceof Error && error.message.includes('BLOCKED')) {
|
||||
throw error;
|
||||
}
|
||||
this.log('debug', 'Could not verify element (may not exist yet)', { selector, error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss any visible Chakra UI modal popups that might block interactions.
|
||||
* This handles various modal dismiss patterns including close buttons and overlay clicks.
|
||||
* Optimized for speed - uses instant visibility checks and minimal waits.
|
||||
*/
|
||||
async dismissModals(): Promise<void> {
|
||||
const page = this.browserSession.getPage();
|
||||
if (!page) return;
|
||||
|
||||
try {
|
||||
const modalContainer = page.locator('.chakra-modal__content-container, .modal-content');
|
||||
const isModalVisible = await modalContainer.isVisible().catch(() => false);
|
||||
|
||||
if (!isModalVisible) {
|
||||
this.log('debug', 'No modal visible, continuing');
|
||||
return;
|
||||
}
|
||||
|
||||
this.log('info', 'Modal detected, dismissing immediately');
|
||||
|
||||
const dismissButton = 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();
|
||||
|
||||
if (await dismissButton.isVisible().catch(() => false)) {
|
||||
this.log('info', 'Clicking modal dismiss button');
|
||||
await dismissButton.click({ force: true, timeout: 1000 });
|
||||
await page.waitForTimeout(100);
|
||||
return;
|
||||
}
|
||||
|
||||
this.log('debug', 'No dismiss button found, skipping Escape to avoid closing wizard');
|
||||
await 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.
|
||||
*/
|
||||
async dismissDatetimePickers(): Promise<void> {
|
||||
const page = this.browserSession.getPage();
|
||||
if (!page) return;
|
||||
|
||||
try {
|
||||
const initialCount = await 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: remove rdtOpen class via JS
|
||||
await page.evaluate(() => {
|
||||
const openPickers = document.querySelectorAll('.rdt.rdtOpen');
|
||||
openPickers.forEach((picker) => {
|
||||
picker.classList.remove('rdtOpen');
|
||||
});
|
||||
const activeEl = document.activeElement as HTMLElement;
|
||||
if (activeEl && activeEl.blur && activeEl.closest('.rdt')) {
|
||||
activeEl.blur();
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
let stillOpenCount = await page.locator('.rdt.rdtOpen').count();
|
||||
if (stillOpenCount === 0) {
|
||||
this.log('debug', 'Datetime pickers closed via JavaScript');
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 2: click outside
|
||||
this.log('debug', `${stillOpenCount} picker(s) still open, clicking outside`);
|
||||
const modalBody = page.locator(IRACING_SELECTORS.wizard.modalContent).first();
|
||||
if (await modalBody.isVisible().catch(() => false)) {
|
||||
const cardHeader = page.locator(`${IRACING_SELECTORS.wizard.stepContainers.timeOfDay} .card-header`).first();
|
||||
if (await cardHeader.isVisible().catch(() => false)) {
|
||||
await cardHeader.click({ force: true, timeout: 1000 }).catch(() => {});
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
}
|
||||
|
||||
stillOpenCount = await page.locator('.rdt.rdtOpen').count();
|
||||
if (stillOpenCount === 0) {
|
||||
this.log('debug', 'Datetime pickers closed via click outside');
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 3: blur inputs and force-remove rdtOpen
|
||||
this.log('debug', `${stillOpenCount} picker(s) still open, force blur`);
|
||||
await page.evaluate(() => {
|
||||
const rdtInputs = document.querySelectorAll('.rdt input');
|
||||
rdtInputs.forEach((input) => {
|
||||
(input as HTMLElement).blur();
|
||||
});
|
||||
const openPickers = document.querySelectorAll('.rdt.rdtOpen');
|
||||
openPickers.forEach((picker) => {
|
||||
picker.classList.remove('rdtOpen');
|
||||
const pickerDropdown = picker.querySelector('.rdtPicker') as HTMLElement;
|
||||
if (pickerDropdown) {
|
||||
pickerDropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
const finalCount = await 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) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async safeClick(
|
||||
selector: string,
|
||||
options?: { timeout?: number; force?: boolean },
|
||||
): Promise<void> {
|
||||
const page = this.getPage();
|
||||
|
||||
// In mock mode, ensure mock fixtures are visible (remove 'hidden' flags)
|
||||
if (!this.isRealMode()) {
|
||||
try {
|
||||
await page.evaluate(() => {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>('.wizard-step.hidden, .modal.hidden, .wizard-step[hidden]')
|
||||
.forEach((el) => {
|
||||
el.classList.remove('hidden');
|
||||
el.removeAttribute('hidden');
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// ignore any evaluation errors in test environments
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const useForce = options?.force || attempt === maxRetries;
|
||||
await page.click(selector, { timeout, force: useForce });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('BLOCKED')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const errorMessage = String(error);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
await this.dismissDatetimePickers();
|
||||
await this.dismissModals();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
this.log('warn', 'Max retries reached, attempting JS click fallback', { selector });
|
||||
|
||||
try {
|
||||
const clicked = await page.evaluate((sel) => {
|
||||
try {
|
||||
const el = document.querySelector(sel) as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
el.click();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, selector);
|
||||
|
||||
if (clicked) {
|
||||
this.log('info', 'JS fallback click succeeded', { selector });
|
||||
return;
|
||||
} else {
|
||||
this.log('debug', 'JS fallback click did not find element or failed', { selector });
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('debug', 'JS fallback click error', { selector, error: String(e) });
|
||||
}
|
||||
|
||||
this.log('error', 'Max retries reached, click still blocked', { selector });
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user