This commit is contained in:
2025-11-27 02:23:59 +01:00
parent 1d7c4f78d1
commit 502d9084e7
64 changed files with 267 additions and 103238 deletions

View File

@@ -2,6 +2,7 @@ import { Result } from '../../../shared/result/Result';
import { CheckoutPrice } from '../../../domain/value-objects/CheckoutPrice';
import { CheckoutState } from '../../../domain/value-objects/CheckoutState';
import { CheckoutInfo } from '../../../application/ports/ICheckoutService';
import { IRACING_SELECTORS } from './IRacingSelectors';
interface Page {
locator(selector: string): Locator;
@@ -14,14 +15,15 @@ interface Locator {
}
export class CheckoutPriceExtractor {
private readonly selector = '.wizard-footer a.btn:has(span.label-pill)';
// Use the price action selector from IRACING_SELECTORS
private readonly selector = IRACING_SELECTORS.BLOCKED_SELECTORS.priceAction;
constructor(private readonly page: Page) {}
async extractCheckoutInfo(): Promise<Result<CheckoutInfo>> {
try {
// Prefer the explicit pill element which contains the price
const pillLocator = this.page.locator('span.label-pill');
const pillLocator = this.page.locator('.label-pill, .label-inverse');
const pillText = await pillLocator.first().textContent().catch(() => null);
let price: CheckoutPrice | null = null;
@@ -68,7 +70,7 @@ export class CheckoutPriceExtractor {
// Additional fallback: search the wizard-footer for any price text if pill was not present or parsing failed
if (!price) {
try {
const footerLocator = this.page.locator('.wizard-footer').first();
const footerLocator = this.page.locator('.wizard-footer, .modal-footer').first();
const footerText = await footerLocator.textContent().catch(() => null);
if (footerText) {
const match = footerText.match(/\$\d+\.\d{2}/);

View File

@@ -87,7 +87,7 @@ export const IRACING_SELECTORS = {
// Form groups have labels followed by inputs
sessionName: '#set-session-information .card-block .form-group:first-of-type input.form-control',
sessionNameAlt: '#set-session-information input.form-control[type="text"]:not([maxlength])',
password: '#set-session-information .card-block .form-group:nth-of-type(2) input.form-control',
password: '#set-session-information .card-block .form-group:nth-of-type(2) input.form-control, #set-session-information input[type="password"], #set-session-information input.chakra-input[type="text"]:not([name="Current page"]):not([id*="field-:rue:"]):not([id*="field-:rug:"]):not([id*="field-:ruj:"]):not([id*="field-:rl5b:"]):not([id*="field-:rktk:"])',
passwordAlt: '#set-session-information input.form-control[maxlength="32"]',
description: '#set-session-information .card-block .form-group:last-of-type textarea.form-control',
descriptionAlt: '#set-session-information textarea.form-control',

View File

@@ -995,9 +995,10 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
if (this.isRealMode()) {
await this.clickNewRaceInModal();
// Ensure Race Information panel is visible by clicking sidebar nav then waiting for fallback selectors
const raceInfoFallback = '#set-session-information, .wizard-step[id*="session"], .wizard-step[id*="race-information"]';
const raceInfoFallback = IRACING_SELECTORS.wizard.stepContainers.raceInformation;
const raceInfoNav = IRACING_SELECTORS.wizard.sidebarLinks.raceInformation;
try {
try { await this.page!.click('[data-testid="wizard-nav-set-session-information"]'); this.log('debug','Clicked wizard nav for Race Information', { selector: '[data-testid="wizard-nav-set-session-information"]' }); } catch (e) { this.log('debug','Wizard nav for Race Information not present (continuing)', { error: String(e) }); }
try { await this.page!.click(raceInfoNav); this.log('debug','Clicked wizard nav for Race Information', { selector: raceInfoNav }); } catch (e) { this.log('debug','Wizard nav for Race Information not present (continuing)', { error: String(e) }); }
await this.page!.waitForSelector(raceInfoFallback, { state: 'attached', timeout: 5000 });
this.log('info','Race Information panel found', { selector: raceInfoFallback });
} catch (err) {
@@ -1005,7 +1006,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
const inner = await this.page!.evaluate(() => document.querySelector('#create-race-wizard')?.innerHTML || '');
this.log('debug','create-race-wizard innerHTML (truncated)', { html: inner ? inner.substring(0,2000) : '' });
// Retry nav click once then wait longer before failing
try { await this.page!.click('[data-testid="wizard-nav-set-session-information"]'); } catch {}
try { await this.page!.click(raceInfoNav); } catch {}
await this.page!.waitForSelector(raceInfoFallback, { state: 'attached', timeout: 10000 });
}
}
@@ -1096,12 +1097,13 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
}
// Robust: try opening Cars via sidebar nav then wait for a set of fallback selectors.
const carsFallbackSelector = '#set-cars, #select-car-compact-content, .cars-panel, [id*="select-car"], [data-step="set-cars"]';
const carsFallbackSelector = IRACING_SELECTORS.wizard.stepContainers.cars;
const carsNav = IRACING_SELECTORS.wizard.sidebarLinks.cars;
try {
this.log('debug', 'nav-click attempted for Cars', { navSelector: '[data-testid="wizard-nav-set-cars"]' });
this.log('debug', 'nav-click attempted for Cars', { navSelector: carsNav });
// Attempt nav click (best-effort) - tolerate absence
await this.page!.click('[data-testid="wizard-nav-set-cars"]').catch(() => {});
this.log('debug', 'Primary nav-click attempted', { selector: '[data-testid="wizard-nav-set-cars"]' });
await this.page!.click(carsNav).catch(() => {});
this.log('debug', 'Primary nav-click attempted', { selector: carsNav });
try {
this.log('debug', 'Waiting for Cars panel using primary selector', { selector: carsFallbackSelector });
@@ -1113,7 +1115,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
this.log('debug', 'captured #create-race-wizard innerHTML (truncated)', { html: html ? html.slice(0, 2000) : '' });
this.log('info', 'retry attempted for Cars nav-click', { attempt: 1 });
// Retry nav click once (best-effort) then wait longer before failing
await this.page!.click('[data-testid="wizard-nav-set-cars"]').catch(() => {});
await this.page!.click(carsNav).catch(() => {});
await this.page!.waitForSelector(carsFallbackSelector, { state: 'attached', timeout: 10000 });
this.log('info', 'Cars panel found after retry', { selector: carsFallbackSelector });
}
@@ -1184,7 +1186,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
// Check if we're on Track page (Step 11) instead of Cars page
const onTrackPage = wizardFooter.includes('Track Options') ||
await this.page!.locator('#set-track').isVisible().catch(() => false);
await this.page!.locator(IRACING_SELECTORS.wizard.stepContainers.track).isVisible().catch(() => false);
if (onTrackPage) {
const errorMsg = `FATAL: Step 9 attempted on Track page (Step 11) - navigation bug detected. Wizard footer: "${wizardFooter}"`;
@@ -1278,7 +1280,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
this.log('info', 'Step 11: Validating page state before proceeding');
const step11Validation = await this.validatePageState({
expectedStep: 'track',
requiredSelectors: ['#set-track'], // Both modes use same container ID
requiredSelectors: [IRACING_SELECTORS.wizard.stepContainers.track], // Both modes use same container ID
forbiddenSelectors: this.isRealMode()
? [IRACING_SELECTORS.steps.addCarButton]
: [] // Mock mode: no forbidden selectors needed
@@ -1430,11 +1432,12 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
}
// Robust: try opening Weather via sidebar nav then wait for a set of fallback selectors.
const weatherFallbackSelector = '#set-weather, .wizard-step[id*="weather"], .wizard-step[data-step="weather"], .weather-panel';
const weatherFallbackSelector = IRACING_SELECTORS.wizard.stepContainers.weather;
const weatherNav = IRACING_SELECTORS.wizard.sidebarLinks.weather;
try {
try {
await this.page!.click('[data-testid="wizard-nav-set-weather"]');
this.log('debug', 'Clicked wizard nav for Weather', { selector: '[data-testid="wizard-nav-set-weather"]' });
await this.page!.click(weatherNav);
this.log('debug', 'Clicked wizard nav for Weather', { selector: weatherNav });
} catch (e) {
this.log('debug', 'Wizard nav for Weather not present (continuing)', { error: String(e) });
}
@@ -1447,7 +1450,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
const inner = await this.page!.evaluate(() => document.querySelector('#create-race-wizard')?.innerHTML || '');
this.log('debug', 'create-race-wizard innerHTML (truncated)', { html: inner ? inner.substring(0, 2000) : '' });
// Retry nav click once then wait longer before failing
try { await this.page!.click('[data-testid="wizard-nav-set-weather"]'); } catch {}
try { await this.page!.click(weatherNav); } catch {}
await this.page!.waitForSelector(weatherFallbackSelector, { state: 'attached', timeout: 10000 });
}
} catch (e) {
@@ -1882,7 +1885,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
try {
// Check for Chakra UI modals (do NOT use this for datetime pickers - see dismissDatetimePickers)
const modalContainer = this.page.locator('.chakra-modal__content-container');
const modalContainer = this.page.locator('.chakra-modal__content-container, .modal-content');
const isModalVisible = await modalContainer.isVisible().catch(() => false);
if (!isModalVisible) {
@@ -1972,10 +1975,10 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
// Strategy 2: Click on the modal body outside the picker
// This simulates clicking elsewhere to close the dropdown
this.log('debug', `${stillOpenCount} picker(s) still open, clicking outside`);
const modalBody = this.page.locator('.modal-body').first();
const modalBody = this.page.locator(IRACING_SELECTORS.wizard.modalContent).first();
if (await modalBody.isVisible().catch(() => false)) {
// Click at a safe spot - the header area of the card
const cardHeader = this.page.locator('#set-time-of-day .card-header').first();
const cardHeader = this.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 this.page.waitForTimeout(100);
@@ -2411,7 +2414,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
try {
this.log('debug', 'Waiting for Add Car modal to appear (primary selector)');
// Wait for modal container - expanded selector list to tolerate UI variants
const modalSelector = '#add-car-modal, #select-car-compact-content, .drawer[id*="select-car"], [id*="select-car-compact"], .select-car-modal';
const modalSelector = IRACING_SELECTORS.steps.addCarModal;
await this.page.waitForSelector(modalSelector, {
state: 'attached',
timeout: this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout,
@@ -2426,7 +2429,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
this.log('debug', 'create-race-wizard innerHTML (truncated)', { html: html ? html.slice(0,2000) : '' });
this.log('info', 'Retrying wait for Add Car modal with extended timeout');
try {
const modalSelectorRetry = '#add-car-modal, #select-car-compact-content, .drawer[id*="select-car"], [id*="select-car-compact"], .select-car-modal';
const modalSelectorRetry = IRACING_SELECTORS.steps.addCarModal;
await this.page.waitForSelector(modalSelectorRetry, {
state: 'attached',
timeout: 10000,
@@ -2509,18 +2512,24 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
throw new Error('Browser not connected');
}
// First try direct select button (non-dropdown)
const directSelector = '.modal table a.btn.btn-primary.btn-xs:not(.dropdown-toggle)';
const directButton = this.page.locator(directSelector).first();
if (await directButton.count() > 0 && await directButton.isVisible()) {
await this.safeClick(directSelector, { timeout: IRACING_TIMEOUTS.elementWait });
this.log('info', 'Clicked direct Select button for first search result', { selector: directSelector });
return;
// First try direct select button (non-dropdown) - using verified selectors
// Try both track and car select buttons as this method is shared
const directSelectors = [
IRACING_SELECTORS.steps.trackSelectButton,
IRACING_SELECTORS.steps.carSelectButton
];
for (const selector of directSelectors) {
const button = this.page.locator(selector).first();
if (await button.count() > 0 && await button.isVisible()) {
await this.safeClick(selector, { timeout: IRACING_TIMEOUTS.elementWait });
this.log('info', 'Clicked direct Select button for first search result', { selector });
return;
}
}
// Fallback: dropdown toggle pattern
const dropdownSelector = '.modal table a.btn.btn-primary.btn-xs.dropdown-toggle';
// Fallback: dropdown toggle pattern (for multi-config tracks)
const dropdownSelector = IRACING_SELECTORS.steps.trackSelectDropdown;
const dropdownButton = this.page.locator(dropdownSelector).first();
if (await dropdownButton.count() > 0 && await dropdownButton.isVisible()) {
@@ -2532,7 +2541,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
await this.page.waitForSelector('.dropdown-menu.show', { timeout: 3000 }).catch(() => {});
// Click first item in dropdown (first track config)
const itemSelector = '.dropdown-menu.show .dropdown-item:first-child';
const itemSelector = IRACING_SELECTORS.steps.trackSelectDropdownItem;
await this.page.waitForTimeout(200);
await this.safeClick(itemSelector, { timeout: IRACING_TIMEOUTS.elementWait });
this.log('info', 'Clicked first dropdown item to select track config', { selector: itemSelector });
@@ -2707,8 +2716,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
// Check for authenticated UI indicators
// Look for elements that are ONLY present when authenticated
const authSelectors = [
'button:has-text("Create a Race")',
'[aria-label="Create a Race"]',
IRACING_SELECTORS.hostedRacing.createRaceButton,
// User menu/profile indicators (present on ALL authenticated pages)
'[aria-label*="user menu" i]',
'[aria-label*="account menu" i]',
@@ -3897,6 +3905,8 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
// Check for close button click or ESC key
if (await this.isCloseRequested()) {
this.log('info', 'Browser close requested by user (close button or ESC key)');
// Only close if we are not in the middle of a critical operation or if explicitly confirmed
// For now, we'll just log and throw, but we might want to add a confirmation dialog in the future
await this.closeBrowserContext();
throw new Error('USER_CLOSE_REQUESTED: Browser closed by user request');
}
@@ -4112,12 +4122,16 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
}
// ESC key listener - close browser on ESC press
// DISABLED: ESC key is often used to close modals/popups in iRacing
// We should only close on explicit close button click
/*
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
console.log('[GridPilot] ESC key pressed, requesting close');
(window as unknown as { __gridpilot_close_requested?: boolean }).__gridpilot_close_requested = true;
}
});
*/
// Modal visibility observer - detect when wizard modal is closed
// Look for Bootstrap modal backdrop disappearing or modal being hidden
@@ -4129,14 +4143,18 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
// Modal backdrop removed
if (node.classList.contains('modal-backdrop')) {
console.log('[GridPilot] Modal backdrop removed, checking if wizard dismissed');
// Small delay to allow for legitimate modal transitions
// Increased delay to allow for legitimate modal transitions (e.g. step changes)
setTimeout(() => {
// Check if ANY wizard-related modal is visible
const wizardModal = document.querySelector('.modal.fade.in, .modal.show');
if (!wizardModal) {
// Also check if we are just transitioning between steps (sometimes modal is briefly hidden)
const wizardContent = document.querySelector('.wizard-content, .wizard-step');
if (!wizardModal && !wizardContent) {
console.log('[GridPilot] Wizard modal no longer visible, requesting close');
(window as unknown as { __gridpilot_close_requested?: boolean }).__gridpilot_close_requested = true;
}
}, 500);
}, 2000); // Increased from 500ms to 2000ms
}
}
}