Files
gridpilot.gg/packages/infrastructure/adapters/automation/PlaywrightAutomationAdapter.ts
2025-11-26 17:03:29 +01:00

4051 lines
152 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { chromium } from 'playwright-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import { Browser, Page, BrowserContext } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
// Add stealth plugin to avoid bot detection
chromium.use(StealthPlugin());
import { StepId } from '../../../domain/value-objects/StepId';
import { AuthenticationState } from '../../../domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from '../../../domain/value-objects/BrowserAuthenticationState';
import { CheckoutPrice } from '../../../domain/value-objects/CheckoutPrice';
import { CheckoutState } from '../../../domain/value-objects/CheckoutState';
import { CheckoutConfirmation } from '../../../domain/value-objects/CheckoutConfirmation';
import type {
IBrowserAutomation,
NavigationResult,
FormFillResult,
ClickResult,
WaitResult,
ModalResult,
AutomationResult,
} from '../../../application/ports/IScreenAutomation';
import type { IAuthenticationService } from '../../../application/ports/IAuthenticationService';
import type { ILogger } from '../../../application/ports/ILogger';
import { Result } from '../../../shared/result/Result';
import { IRACING_SELECTORS, IRACING_URLS, IRACING_TIMEOUTS, ALL_BLOCKED_SELECTORS, BLOCKED_KEYWORDS } from './IRacingSelectors';
import { SessionCookieStore } from './SessionCookieStore';
import { AuthenticationGuard } from './AuthenticationGuard';
import { BrowserModeConfigLoader, BrowserMode } from '../../config/BrowserModeConfig';
import { getAutomationMode } from '../../config/AutomationConfig';
import { PageStateValidator, PageStateValidation, PageStateValidationResult } from '../../../domain/services/PageStateValidator';
export type AutomationAdapterMode = 'mock' | 'real';
/**
* Personality messages for the automation overlay.
* These add a fun, racing-themed personality to the bot.
*/
const OVERLAY_PERSONALITY_MESSAGES = [
"🏎️ Warming up the tires...",
"🔧 Fine-tuning the setup...",
"🏁 Getting ready for the green flag...",
"⚡ Optimizing lap times...",
"🎯 Locking in your preferences...",
"🌟 Making racing dreams come true...",
"🚀 Preparing for launch...",
"🏆 Setting you up for victory...",
"🎮 Configuring the perfect session...",
"⏱️ Every millisecond counts...",
"🛞 Checking tire pressures...",
"📡 Syncing with race control...",
"🔥 Engines are warming up...",
"💨 Almost race time!",
"🗺️ Plotting the racing line...",
];
/**
* Step-specific messages for the overlay.
* Maps step numbers to friendly descriptions.
*/
const OVERLAY_STEP_MESSAGES: Record<number, string> = {
1: "🔐 Checking your credentials...",
2: "🏁 Creating your race session...",
3: "📝 Setting up race information...",
4: "🖥️ Configuring server details...",
5: "👥 Managing admin access...",
6: " Adding admin privileges...",
7: "⏰ Setting time limits...",
8: "🚗 Selecting your cars...",
9: "🏎️ Adding your car to the grid...",
10: "🎨 Configuring car classes...",
11: "🗺️ Choosing your track...",
12: "🏟️ Adding track to session...",
13: "⚙️ Setting track options...",
14: "🌅 Configuring time of day...",
15: "🌤️ Setting weather conditions...",
16: "🌦️ Configuring track conditions...",
17: "✅ Done! Review your settings and click 'Host Race' to create your session!",
};
/**
* CSS styles for the automation overlay.
* Styled to match iRacing's dark theme with racing accents.
* Colors extracted from iRacing HTML fixtures:
* - Primary dark: #12121B (iRacing brand dark)
* - Gray background: #1a1a24 (gray-800 equivalent)
* - Text light: rgba(255, 255, 255, 0.92) (whiteAlpha-900)
* - Accent: #c8102e (iRacing red)
* - Button active: #4e4e57
* - Border: rgba(183, 183, 187, 0.3)
*/
const OVERLAY_CSS = `
@keyframes gridpilot-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.85; transform: scale(1.03); }
}
@keyframes gridpilot-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes gridpilot-slide-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes gridpilot-checkered {
0% { background-position: 0 0; }
100% { background-position: 20px 20px; }
}
@keyframes gridpilot-progress {
0% { background-position: 0% 50%; }
100% { background-position: 100% 50%; }
}
#gridpilot-overlay {
position: fixed;
bottom: 20px;
right: 20px;
width: 340px;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
z-index: 2147483647;
animation: gridpilot-slide-in 0.4s ease-out;
pointer-events: auto;
}
#gridpilot-overlay * {
box-sizing: border-box;
}
.gridpilot-card {
background: #12121B;
border-radius: 4px;
border: 1px solid rgba(183, 183, 187, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
overflow: hidden;
}
.gridpilot-header {
background: linear-gradient(90deg, #c8102e 0%, #a00d25 100%);
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
position: relative;
overflow: hidden;
}
.gridpilot-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
linear-gradient(45deg, transparent 48%, rgba(255,255,255,0.05) 49%, rgba(255,255,255,0.05) 51%, transparent 52%),
linear-gradient(-45deg, transparent 48%, rgba(255,255,255,0.05) 49%, rgba(255,255,255,0.05) 51%, transparent 52%);
background-size: 8px 8px;
animation: gridpilot-checkered 1.5s linear infinite;
opacity: 0.5;
}
.gridpilot-logo {
font-size: 22px;
animation: gridpilot-pulse 2s ease-in-out infinite;
position: relative;
z-index: 1;
}
.gridpilot-title {
color: #ffffff;
font-size: 13px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
position: relative;
z-index: 1;
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
flex: 1;
}
.gridpilot-btn {
background: rgba(255, 255, 255, 0.15);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
cursor: pointer;
position: relative;
z-index: 1;
transition: all 0.15s ease;
}
.gridpilot-btn:hover {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.5);
}
.gridpilot-btn:active {
background: rgba(255, 255, 255, 0.35);
transform: scale(0.97);
}
.gridpilot-btn.paused {
background: #4e4e57;
border-color: #ffffff;
color: #ffffff;
animation: gridpilot-pulse 1s ease-in-out infinite;
}
.gridpilot-close-btn {
background: rgba(200, 16, 46, 0.6);
border-color: rgba(200, 16, 46, 0.8);
}
.gridpilot-close-btn:hover {
background: rgba(200, 16, 46, 0.8);
border-color: #c8102e;
}
.gridpilot-close-btn:active {
background: #c8102e;
}
.gridpilot-header-buttons {
display: flex;
gap: 6px;
position: relative;
z-index: 1;
}
.gridpilot-body {
padding: 14px;
background: #1a1a24;
}
.gridpilot-status {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.gridpilot-spinner {
width: 22px;
height: 22px;
border: 2px solid rgba(200, 16, 46, 0.3);
border-top-color: #c8102e;
border-radius: 50%;
animation: gridpilot-spin 0.8s linear infinite;
flex-shrink: 0;
}
.gridpilot-spinner.paused {
animation-play-state: paused;
border-top-color: #777880;
border-color: rgba(119, 120, 128, 0.3);
}
.gridpilot-action-text {
color: rgba(255, 255, 255, 0.92);
font-size: 14px;
font-weight: 500;
line-height: 1.4;
}
.gridpilot-progress-container {
margin-bottom: 12px;
}
.gridpilot-progress-bar {
height: 4px;
background: rgba(78, 78, 87, 0.5);
border-radius: 2px;
overflow: hidden;
}
.gridpilot-progress-fill {
height: 100%;
background: linear-gradient(90deg, #c8102e, #e8304a, #c8102e);
background-size: 200% 100%;
animation: gridpilot-progress 2s linear infinite;
border-radius: 2px;
transition: width 0.4s ease-out;
}
.gridpilot-progress-fill.paused {
animation-play-state: paused;
background: #777880;
}
.gridpilot-step-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 6px;
}
.gridpilot-step-text {
color: rgba(255, 255, 255, 0.6);
font-size: 11px;
}
.gridpilot-step-count {
color: #c8102e;
font-size: 11px;
font-weight: 600;
}
.gridpilot-personality {
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
font-style: italic;
text-align: center;
padding-top: 10px;
border-top: 1px solid rgba(183, 183, 187, 0.15);
}
.gridpilot-footer {
background: #12121B;
padding: 8px 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border-top: 1px solid rgba(183, 183, 187, 0.1);
}
.gridpilot-footer-text {
color: rgba(255, 255, 255, 0.4);
font-size: 10px;
letter-spacing: 0.5px;
}
.gridpilot-footer-dot {
width: 4px;
height: 4px;
background: #c8102e;
border-radius: 50%;
animation: gridpilot-pulse 1.5s ease-in-out infinite;
}
.gridpilot-footer-dot.paused {
background: #777880;
animation: none;
}
`;
/**
* HTML template for the automation overlay.
* Includes pause/resume button and close button for user control.
*/
const OVERLAY_HTML = `
<div id="gridpilot-overlay">
<div class="gridpilot-card">
<div class="gridpilot-header">
<span class="gridpilot-logo">🏎️</span>
<span class="gridpilot-title">GridPilot</span>
<div class="gridpilot-header-buttons">
<button class="gridpilot-btn" id="gridpilot-pause-btn" onclick="(function(btn) {
window.__gridpilot_paused = !window.__gridpilot_paused;
btn.textContent = window.__gridpilot_paused ? 'Resume' : 'Pause';
btn.classList.toggle('paused', window.__gridpilot_paused);
document.querySelector('.gridpilot-spinner')?.classList.toggle('paused', window.__gridpilot_paused);
document.querySelector('.gridpilot-progress-fill')?.classList.toggle('paused', window.__gridpilot_paused);
document.querySelector('.gridpilot-footer-dot')?.classList.toggle('paused', window.__gridpilot_paused);
var actionEl = document.getElementById('gridpilot-action');
if (actionEl && window.__gridpilot_paused) {
actionEl.dataset.prevText = actionEl.textContent;
actionEl.textContent = '⏸️ Paused - Click Resume to continue';
} else if (actionEl && actionEl.dataset.prevText) {
actionEl.textContent = actionEl.dataset.prevText;
}
})(this)">Pause</button>
<button class="gridpilot-btn gridpilot-close-btn" id="gridpilot-close-btn" onclick="(function() {
window.__gridpilot_close_requested = true;
})()">✕</button>
</div>
</div>
<div class="gridpilot-body">
<div class="gridpilot-status">
<div class="gridpilot-spinner"></div>
<span class="gridpilot-action-text" id="gridpilot-action">Initializing...</span>
</div>
<div class="gridpilot-progress-container">
<div class="gridpilot-progress-bar">
<div class="gridpilot-progress-fill" id="gridpilot-progress" style="width: 0%"></div>
</div>
<div class="gridpilot-step-info">
<span class="gridpilot-step-text" id="gridpilot-step-text">Starting up...</span>
<span class="gridpilot-step-count" id="gridpilot-step-count">Step 0 of 17</span>
</div>
</div>
<div class="gridpilot-personality" id="gridpilot-personality">🏁 Getting ready for the green flag...</div>
</div>
<div class="gridpilot-footer">
<div class="gridpilot-footer-dot"></div>
<span class="gridpilot-footer-text">Automating your session setup</span>
</div>
</div>
</div>
`;
export interface PlaywrightConfig {
headless?: boolean;
timeout?: number;
baseUrl?: string;
mode?: AutomationAdapterMode;
/** Path to store persistent browser context (session data). Required for real mode. */
userDataDir?: string;
}
export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthenticationService {
private browser: Browser | null = null;
private persistentContext: BrowserContext | null = null;
private context: BrowserContext | null = null;
private page: Page | null = null;
private config: Required<PlaywrightConfig>;
private connected = false;
private isConnecting = false;
private logger?: ILogger;
private authState: AuthenticationState = AuthenticationState.UNKNOWN;
private cookieStore: SessionCookieStore;
private overlayInjected = false;
private totalSteps = 17;
private browserModeLoader: BrowserModeConfigLoader;
private actualBrowserMode: BrowserMode;
private browserModeSource: 'env' | 'file' | 'default';
/** Polling interval for pause check (ms) */
private static readonly PAUSE_CHECK_INTERVAL = 300;
/** Checkout confirmation callback - called before clicking checkout button */
private checkoutConfirmationCallback?: (price: CheckoutPrice, state: CheckoutState) => Promise<CheckoutConfirmation>;
/** Page state validator instance */
private pageStateValidator: PageStateValidator;
constructor(config: PlaywrightConfig = {}, logger?: ILogger, browserModeLoader?: BrowserModeConfigLoader) {
this.config = {
headless: true,
timeout: 10000,
baseUrl: '',
mode: 'mock',
userDataDir: '',
...config,
};
this.logger = logger;
this.cookieStore = new SessionCookieStore(this.config.userDataDir, logger);
this.pageStateValidator = new PageStateValidator();
// Initialize browser mode configuration (allow injection of loader for tests)
const automationMode = getAutomationMode();
this.browserModeLoader = browserModeLoader ?? new BrowserModeConfigLoader();
const browserModeConfig = this.browserModeLoader.load();
this.actualBrowserMode = browserModeConfig.mode;
this.browserModeSource = browserModeConfig.source as any;
// Log browser mode decision
this.log('info', 'Browser mode configured', {
mode: this.actualBrowserMode,
source: this.browserModeSource,
automationMode,
configHeadless: this.config.headless,
});
}
private isRealMode(): boolean {
return this.config.mode === 'real';
}
/**
* Validate that the current page state matches expected conditions.
* Uses the PageStateValidator domain service to check selector presence/absence.
*
* @param validation Expected page state configuration
* @returns Result with validation outcome
*/
async validatePageState(validation: PageStateValidation): Promise<Result<PageStateValidationResult, Error>> {
if (!this.page) {
return Result.err(new Error('Browser not connected'));
}
try {
// Create a function that checks if selectors exist on the page
const checkSelector = (selector: string): boolean => {
// Synchronously check if selector exists (count > 0)
// We'll need to make this sync-compatible, so we check in the validator call
return false; // Placeholder - will be resolved in evaluate
};
// Use page.evaluate to check all selectors at once in the browser context
const selectorChecks = await this.page.evaluate(
({ requiredSelectors, forbiddenSelectors }) => {
const results: Record<string, boolean> = {};
// Check required selectors
for (const selector of requiredSelectors) {
try {
results[selector] = document.querySelectorAll(selector).length > 0;
} catch {
results[selector] = false;
}
}
// Check forbidden selectors
for (const selector of forbiddenSelectors || []) {
try {
results[selector] = document.querySelectorAll(selector).length > 0;
} catch {
results[selector] = false;
}
}
return results;
},
{
requiredSelectors: validation.requiredSelectors,
forbiddenSelectors: validation.forbiddenSelectors || []
}
);
// Create actualState function that uses the captured results
const actualState = (selector: string): boolean => {
return selectorChecks[selector] === true;
};
// Validate using domain service
return this.pageStateValidator.validateState(actualState, validation);
} catch (error) {
return Result.err(
error instanceof Error
? error
: new Error(`Page state validation failed: ${String(error)}`)
);
}
}
/** Maximum number of "before" debug snapshots to keep */
private static readonly MAX_BEFORE_SNAPSHOTS = 5;
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
if (this.logger) {
this.logger[level](message, context);
}
}
async connect(forceHeaded: boolean = false): Promise<AutomationResult> {
// If already connected, return success
if (this.connected && this.page) {
this.log('debug', 'Already connected, reusing existing connection');
return { success: true };
}
// If currently connecting, wait and retry
if (this.isConnecting) {
this.log('debug', 'Connection in progress, waiting...');
await new Promise(resolve => setTimeout(resolve, 100));
return this.connect(forceHeaded);
}
this.isConnecting = true;
try {
const currentConfig = this.browserModeLoader.load();
// Update cached mode and source so other methods observe the latest config
this.actualBrowserMode = currentConfig.mode;
this.browserModeSource = currentConfig.source as any;
const effectiveMode = forceHeaded ? 'headed' : currentConfig.mode;
// Test hook: use injected testLauncher if present to avoid real Playwright launches
const launcher = (PlaywrightAutomationAdapter as any).testLauncher ?? chromium;
// Instrumentation: log what effective mode is being used for launch
this.log('debug', 'Effective browser mode at connect', {
effectiveMode,
actualBrowserMode: this.actualBrowserMode,
browserModeSource: this.browserModeSource,
forced: forceHeaded,
});
// Test-only console instrumentation (guarded to avoid noise in production)
if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development') {
try {
const loaderValue = this.browserModeLoader && typeof this.browserModeLoader.load === 'function'
? this.browserModeLoader.load()
: undefined;
// Include both loader.load() output and the adapter-reported source
console.debug('[TEST-INSTRUMENT] PlaywrightAutomationAdapter.connect()', {
effectiveMode,
forceHeaded,
loaderValue,
browserModeSource: this.getBrowserModeSource ? this.getBrowserModeSource() : this.browserModeSource,
});
} catch (e) {
// Swallow any errors from test instrumentation
}
}
// In real mode with userDataDir, use persistent context for session persistence
if (this.isRealMode() && this.config.userDataDir) {
this.log('info', 'Launching persistent browser context', {
userDataDir: this.config.userDataDir,
mode: effectiveMode,
forced: forceHeaded
});
// Ensure the directory exists
if (!fs.existsSync(this.config.userDataDir)) {
fs.mkdirSync(this.config.userDataDir, { recursive: true });
}
// Clean up stale lock files before launching
await this.cleanupStaleLockFile(this.config.userDataDir);
this.persistentContext = await launcher.launchPersistentContext(
this.config.userDataDir,
{
headless: effectiveMode === 'headless',
// Stealth options to avoid bot detection
args: [
'--disable-blink-features=AutomationControlled',
'--disable-features=IsolateOrigins,site-per-process',
],
ignoreDefaultArgs: ['--enable-automation'],
// Mimic real browser viewport and user agent
viewport: { width: 1920, height: 1080 },
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
}
);
this.page = this.persistentContext.pages()[0] || await this.persistentContext.newPage();
this.page.setDefaultTimeout(this.config.timeout ?? 10000);
this.connected = true;
return { success: true };
}
// Non-persistent mode (mock or no userDataDir)
this.browser = await launcher.launch({
headless: effectiveMode === 'headless',
// Stealth options to avoid bot detection
args: [
'--disable-blink-features=AutomationControlled',
'--disable-features=IsolateOrigins,site-per-process',
],
ignoreDefaultArgs: ['--enable-automation'],
});
this.context = await this.browser.newContext();
this.page = await this.context.newPage();
this.page.setDefaultTimeout(this.config.timeout ?? 10000);
this.connected = true;
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
} finally {
this.isConnecting = false;
}
}
/**
* Ensure browser context is ready for automation.
* This is a wrapper around connect() that allows forcing headed mode.
*
* @param forceHeaded If true, override browser mode to 'headed' (visible browser)
*/
private async ensureBrowserContext(forceHeaded: boolean = false): Promise<void> {
const result = await this.connect(forceHeaded);
if (!result.success) {
throw new Error(result.error || 'Failed to connect browser');
}
}
/**
* Clean up stale SingletonLock file if it exists and the owning process is not running.
* On Unix systems, SingletonLock is a symbolic link pointing to a socket file.
* If the browser crashed or was force quit, this file remains and blocks new launches.
*/
private async cleanupStaleLockFile(userDataDir: string): Promise<void> {
const singletonLockPath = path.join(userDataDir, 'SingletonLock');
try {
// Check if lock file exists
if (!fs.existsSync(singletonLockPath)) {
return; // No lock file, we're good
}
this.log('info', 'Found existing SingletonLock, attempting cleanup', { path: singletonLockPath });
// Try to remove the lock file
// On Unix, SingletonLock is typically a symlink, so unlink works for both files and symlinks
fs.unlinkSync(singletonLockPath);
this.log('info', 'Cleaned up stale SingletonLock file');
} catch (error) {
// If we can't remove it, the browser might actually be running
// Log warning but continue - Playwright will give us a proper error if it's actually locked
this.log('warn', 'Could not clean up SingletonLock', { error: String(error) });
}
}
async disconnect(): Promise<void> {
if (this.page) {
await this.page.close();
this.page = null;
}
if (this.persistentContext) {
await this.persistentContext.close();
this.persistentContext = null;
}
if (this.context) {
await this.context.close();
this.context = null;
}
if (this.browser) {
await this.browser.close();
this.browser = null;
}
this.connected = false;
}
isConnected(): boolean {
return this.connected && this.page !== null;
}
async navigateToPage(url: string): Promise<NavigationResult> {
if (!this.page) {
return { success: false, error: 'Browser not connected' };
}
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 this.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout });
// In mock mode, extract step number from URL and add data-step attribute to body
// This is needed for getCurrentStep() to work in tests
if (!this.isRealMode()) {
const stepMatch = url.match(/step-(\d+)-/);
if (stepMatch) {
const stepNumber = parseInt(stepMatch[1], 10);
await this.page.evaluate((step) => {
document.body.setAttribute('data-step', String(step));
}, stepNumber);
}
}
// Reset overlay state after navigation (page context changed)
this.resetOverlayState();
return { success: true, url: targetUrl };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
}
}
async fillFormField(fieldName: string, value: string): Promise<FormFillResult> {
if (!this.page) {
return { success: false, fieldName, value, error: 'Browser not connected' };
}
try {
const selector = this.getFieldSelector(fieldName);
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
this.log('debug', 'Filling form field', { fieldName, selector, mode: this.config.mode });
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
await this.page.waitForSelector(selector, { state: 'attached', timeout });
await this.page.fill(selector, value);
return { success: true, fieldName, value };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, fieldName, value, error: message };
}
}
private getFieldSelector(fieldName: string): string {
const fieldMap: Record<string, string> = {
sessionName: `${IRACING_SELECTORS.steps.sessionName}, ${IRACING_SELECTORS.steps.sessionNameAlt}`,
password: `${IRACING_SELECTORS.steps.password}, ${IRACING_SELECTORS.steps.passwordAlt}`,
description: `${IRACING_SELECTORS.steps.description}, ${IRACING_SELECTORS.steps.descriptionAlt}`,
adminSearch: IRACING_SELECTORS.steps.adminSearch,
carSearch: IRACING_SELECTORS.steps.carSearch,
trackSearch: IRACING_SELECTORS.steps.trackSearch,
maxDrivers: IRACING_SELECTORS.steps.maxDrivers,
};
return fieldMap[fieldName] || IRACING_SELECTORS.fields.textInput;
}
async clickElement(target: string): Promise<ClickResult> {
if (!this.page) {
return { success: false, error: 'Browser not connected' };
}
try {
const selector = this.getActionSelector(target);
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
this.log('debug', 'Clicking element', { target, selector, mode: this.config.mode });
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
await this.page.waitForSelector(selector, { state: 'attached', timeout });
await this.page.click(selector);
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
}
}
private getActionSelector(action: string): string {
// If already a selector, return as-is
if (action.startsWith('[') || action.startsWith('button') || action.startsWith('#')) {
return action;
}
const actionMap: Record<string, string> = {
next: IRACING_SELECTORS.wizard.nextButton,
back: IRACING_SELECTORS.wizard.backButton,
confirm: IRACING_SELECTORS.wizard.confirmButton,
cancel: IRACING_SELECTORS.wizard.cancelButton,
create: IRACING_SELECTORS.hostedRacing.createRaceButton,
close: IRACING_SELECTORS.wizard.closeButton,
};
return actionMap[action] || `button:has-text("${action}")`;
}
async waitForElement(target: string, maxWaitMs?: number): Promise<WaitResult> {
if (!this.page) {
return { success: 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 {
// Wait for modal/wizard elements instead of step containers
selector = IRACING_SELECTORS.wizard.modal;
}
this.log('debug', 'Waiting for element', { target, selector, mode: this.config.mode });
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
await this.page.waitForSelector(selector, {
state: 'attached',
timeout: maxWaitMs ?? defaultTimeout,
});
return { success: true, waitTime: Date.now() - startTime };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message, waitTime: Date.now() - startTime };
}
}
async handleModal(_stepId: StepId, action: string): Promise<ModalResult> {
if (!this.page) {
return { success: false, error: 'Browser not connected' };
}
try {
const modalSelector = IRACING_SELECTORS.wizard.modal;
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
this.log('debug', 'Handling modal', { action, mode: this.config.mode });
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
await this.page.waitForSelector(modalSelector, { state: 'attached', timeout });
let buttonSelector: string;
if (action === 'confirm') {
buttonSelector = IRACING_SELECTORS.wizard.confirmButton;
} else if (action === 'cancel') {
buttonSelector = IRACING_SELECTORS.wizard.cancelButton;
} else {
return { success: false, error: `Unknown modal action: ${action}` };
}
await this.page.click(buttonSelector);
return { success: true, action };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
}
}
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
if (!this.page) {
return { success: false, error: 'Browser not connected' };
}
const step = stepId.value;
this.log('info', 'Executing step', { step, mode: this.config.mode });
try {
// Check if paused before proceeding (real mode only)
if (this.isRealMode()) {
await this.waitIfPaused();
// Check if user requested browser close
await this.checkAndHandleClose();
}
// Wizard auto-skip detection and synchronization (real mode only)
// Only check for auto-skip AFTER waiting for the step container
// This ensures we don't prematurely skip steps in mock mode
// The actual skip detection happens later in the switch statement for steps 8-10
// Inject and update overlay at the start of each step (real mode only)
if (this.isRealMode()) {
await this.updateOverlay(step);
}
// Save proactive debug dump BEFORE step execution
// This ensures we have state captured even if Ctrl+C closes the browser
const beforeDebugPaths = await this.saveProactiveDebugInfo(step);
if (beforeDebugPaths.screenshotPath || beforeDebugPaths.htmlPath) {
this.log('info', `Pre-step debug snapshot saved for step ${step}`, {
screenshot: beforeDebugPaths.screenshotPath,
html: beforeDebugPaths.htmlPath,
});
}
// Dismiss any modal popups that might be blocking interactions
await this.dismissModals();
// Step 1: Login handling (real mode only)
if (step === 1 && this.isRealMode()) {
return this.handleLogin();
}
// For real mode, we don't wait for step containers
if (!this.isRealMode()) {
await this.waitForStep(step);
}
switch (step) {
case 1:
// Step 1: Login handling (real mode only) - already handled above
break;
case 2:
// Step 2: Click "Create a Race" button to navigate to step 3
await this.clickAction('create');
break;
case 3:
// Step 3: Race Information - fill session details and navigate to next step
// In mock mode, we're already on the form page (navigated here from step 2)
// In real mode, a modal appears asking "Last Settings" or "New Race" - click "New Race"
if (this.isRealMode()) {
await this.clickNewRaceInModal();
}
// Fill form fields if provided
if (config.sessionName) {
await this.fillFieldWithFallback('sessionName', String(config.sessionName));
}
if (config.password) {
await this.fillFieldWithFallback('password', String(config.password));
}
if (config.description) {
await this.fillFieldWithFallback('description', String(config.description));
}
// Click next to navigate to step 4
await this.clickNextButton('Server Details');
break;
case 4:
// Step 4: Server Details
// In real mode, wait for the wizard step to be visible
if (this.isRealMode()) {
await this.waitForWizardStep('serverDetails');
// Check if wizard was dismissed after confirming step loaded
await this.checkWizardDismissed(step);
}
if (config.region) {
await this.selectDropdown('region', String(config.region));
}
if (config.startNow !== undefined) {
await this.setToggle('startNow', Boolean(config.startNow));
}
await this.clickNextButton('Admins');
break;
case 5:
// Step 5: Set Admins (view admins list)
if (this.isRealMode()) {
await this.waitForWizardStep('admins');
// Check if wizard was dismissed after confirming step loaded
await this.checkWizardDismissed(step);
}
await this.clickNextButton('Time Limit');
break;
case 6:
// Step 6: Set Admins (manage admin permissions)
// This step displays the admin management page where users can add/remove admins
if (this.isRealMode()) {
await this.waitForWizardStep('admins');
// Check if wizard was dismissed after confirming step loaded
await this.checkWizardDismissed(step);
}
await this.clickNextButton('Time Limit');
break;
case 7:
// Step 7: Time Limits
if (this.isRealMode()) {
await this.waitForWizardStep('timeLimit');
// Check if wizard was dismissed after confirming step loaded
await this.checkWizardDismissed(step);
}
if (config.practice !== undefined) {
await this.setSlider('practice', Number(config.practice));
}
if (config.qualify !== undefined) {
await this.setSlider('qualify', Number(config.qualify));
}
if (config.race !== undefined) {
await this.setSlider('race', Number(config.race));
}
await this.clickNextButton('Cars');
break;
case 8:
// Step 8: Set Cars (view only - navigation deferred to Step 9)
if (this.isRealMode()) {
// Check for wizard auto-skip BEFORE trying to interact with the page
const actualPage = await this.detectCurrentWizardPage();
const skipOffset = this.synchronizeStepCounter(step, actualPage);
if (skipOffset > 0) {
// Wizard skipped steps 8-10, we're already on step 11 (Track)
this.log('info', `Step ${step} was auto-skipped by wizard`, { actualPage, skipOffset });
return { success: true };
}
await this.waitForWizardStep('cars');
await this.checkWizardDismissed(step);
}
// CRITICAL: Validate we're on the correct page before proceeding (both modes)
this.log('info', 'Step 8: Validating page state before proceeding');
const step8Validation = await this.validatePageState({
expectedStep: 'cars',
requiredSelectors: this.isRealMode()
? [IRACING_SELECTORS.steps.addCarButton]
: ['#set-cars'], // Mock mode: check for Cars container
forbiddenSelectors: ['#set-track']
});
if (step8Validation.isErr()) {
// Exception during validation
const errorMsg = `Step 8 validation error: ${step8Validation.error.message}`;
this.log('error', errorMsg);
throw new Error(errorMsg);
}
const step8ValidationResult = step8Validation.unwrap();
this.log('info', 'Step 8 validation result', {
isValid: step8ValidationResult.isValid,
message: step8ValidationResult.message,
missingSelectors: step8ValidationResult.missingSelectors,
unexpectedSelectors: step8ValidationResult.unexpectedSelectors
});
if (!step8ValidationResult.isValid) {
// Validation failed - wrong page
const errorMsg = `Step 8 FAILED validation: ${step8ValidationResult.message}`;
this.log('error', errorMsg, {
missing: step8ValidationResult.missingSelectors,
unexpected: step8ValidationResult.unexpectedSelectors
});
throw new Error(errorMsg);
}
this.log('info', 'Step 8 validation passed - on Cars page');
// DO NOT click next - Step 9 will handle navigation
break;
case 9:
// Step 9: Add a Car (modal) + Navigate to Track
// CRITICAL: Validate we're still on Cars page before any actions (both modes)
this.log('info', 'Step 9: Validating we are still on Cars page');
if (this.isRealMode()) {
// Check for wizard auto-skip BEFORE trying to interact with the page
const actualPage = await this.detectCurrentWizardPage();
const skipOffset = this.synchronizeStepCounter(step, actualPage);
if (skipOffset > 0) {
// Wizard skipped steps 8-10, we're already on step 11 (Track)
this.log('info', `Step ${step} was auto-skipped by wizard`, { actualPage, skipOffset });
return { success: true };
}
// Real mode: check wizard footer
const wizardFooter = await this.page!.locator('.wizard-footer').innerText().catch(() => '');
this.log('info', 'Step 9: Current wizard footer', { footer: wizardFooter });
// 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);
if (onTrackPage) {
const errorMsg = `FATAL: Step 9 attempted on Track page (Step 11) - navigation bug detected. Wizard footer: "${wizardFooter}"`;
this.log('error', errorMsg);
throw new Error(errorMsg);
}
}
// Validate page state with selectors (both real and mock mode)
const validation = await this.validatePageState({
expectedStep: 'cars',
requiredSelectors: this.isRealMode()
? [IRACING_SELECTORS.steps.addCarButton]
: ['#set-cars'], // Mock mode: check for Cars container
forbiddenSelectors: ['#set-track']
});
if (validation.isErr()) {
// Exception during validation
const errorMsg = `Step 9 validation error: ${validation.error.message}`;
this.log('error', errorMsg);
throw new Error(errorMsg);
}
const validationResult = validation.unwrap();
this.log('info', 'Step 9 validation result', {
isValid: validationResult.isValid,
message: validationResult.message,
missingSelectors: validationResult.missingSelectors,
unexpectedSelectors: validationResult.unexpectedSelectors
});
if (!validationResult.isValid) {
// Validation failed - wrong page
const errorMsg = `Step 9 FAILED validation: ${validationResult.message}. Browser is ${validationResult.unexpectedSelectors?.includes('#set-track') ? '3 steps ahead on Track page' : 'on wrong page'}`;
this.log('error', errorMsg, {
missing: validationResult.missingSelectors,
unexpected: validationResult.unexpectedSelectors
});
throw new Error(errorMsg);
}
this.log('info', 'Step 9 validation passed - confirmed on Cars page');
if (this.isRealMode()) {
const carIds = config.carIds as string[] | undefined;
const carSearchTerm = config.carSearch || config.car || carIds?.[0];
if (carSearchTerm) {
await this.clickAddCarButton();
await this.waitForAddCarModal();
await this.fillField('carSearch', String(carSearchTerm));
await this.page!.waitForTimeout(500);
await this.selectFirstSearchResult();
this.log('info', 'Added car to session', { car: carSearchTerm });
}
// Navigate to Car Classes page
await this.clickNextButton('Car Classes');
} else {
// Mock mode
if (config.carSearch) {
await this.fillField('carSearch', String(config.carSearch));
await this.clickAction('confirm');
}
// Navigate to Car Classes
await this.clickNextButton('Car Classes');
}
break;
case 10:
// Step 10: Car Classes - navigate to Track
if (this.isRealMode()) {
// Check for wizard auto-skip BEFORE trying to interact with the page
const actualPage = await this.detectCurrentWizardPage();
const skipOffset = this.synchronizeStepCounter(step, actualPage);
if (skipOffset > 0) {
// Wizard skipped steps 8-10, we're already on step 11 (Track)
this.log('info', `Step ${step} was auto-skipped by wizard`, { actualPage, skipOffset });
return { success: true };
}
}
await this.clickNextButton('Track');
break;
case 11:
// Step 11: Set Track (page already loaded by Step 9)
// CRITICAL: Validate we're on Track page (both modes)
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
forbiddenSelectors: this.isRealMode()
? [IRACING_SELECTORS.steps.addCarButton]
: [] // Mock mode: no forbidden selectors needed
});
if (step11Validation.isErr()) {
// Exception during validation
const errorMsg = `Step 11 validation error: ${step11Validation.error.message}`;
this.log('error', errorMsg);
throw new Error(errorMsg);
}
const step11ValidationResult = step11Validation.unwrap();
this.log('info', 'Step 11 validation result', {
isValid: step11ValidationResult.isValid,
message: step11ValidationResult.message,
missingSelectors: step11ValidationResult.missingSelectors,
unexpectedSelectors: step11ValidationResult.unexpectedSelectors
});
if (!step11ValidationResult.isValid) {
// Validation failed - wrong page
const errorMsg = `Step 11 FAILED validation: ${step11ValidationResult.message}`;
this.log('error', errorMsg, {
missing: step11ValidationResult.missingSelectors,
unexpected: step11ValidationResult.unexpectedSelectors
});
throw new Error(errorMsg);
}
this.log('info', 'Step 11 validation passed - on Track page');
if (this.isRealMode()) {
await this.waitForWizardStep('track');
await this.checkWizardDismissed(step);
}
// Track step now - continue with track logic
break;
case 12:
// Step 12: Set Track
if (this.isRealMode()) {
await this.waitForWizardStep('track');
}
// Just wait for the Track step and click next - track selection is a separate step
await this.clickNextButton('Track Options');
break;
case 13:
// Step 13: Track Options
if (this.isRealMode()) {
// Auto-skip detection
const actualPage = await this.detectCurrentWizardPage();
const skipOffset = this.synchronizeStepCounter(step, actualPage);
if (skipOffset > 0) {
this.log('info', `Step ${step} was auto-skipped by wizard`, {
actualPage,
skippedSteps: Array.from({ length: skipOffset }, (_, i) => step + i)
});
return { success: true };
}
await this.waitForWizardStep('trackOptions');
await this.checkWizardDismissed(step);
const trackSearchTerm = config.trackSearch || config.track || config.trackId;
if (trackSearchTerm) {
// First, click the "Add Track" / "Select Track" button to open the modal
await this.clickAddTrackButton();
// Wait for the modal to appear
await this.waitForAddTrackModal();
// Search for the track
await this.fillField('trackSearch', String(trackSearchTerm));
// Wait for search results to load
await this.page!.waitForTimeout(500);
// Select the first result by clicking its "Select" button
// This immediately selects the track (no confirm step needed - modal closes automatically)
await this.selectFirstSearchResult();
// After selecting track, wait for modal to actually close
const modalClosed = await this.page!.waitForSelector('.modal.fade.in', { state: 'hidden', timeout: 5000 })
.then(() => true)
.catch(() => false);
if (!modalClosed) {
this.log('warn', 'Track selection modal did not close, attempting dismiss');
await this.dismissModals();
await this.page!.waitForTimeout(300);
}
// Brief pause before attempting navigation
await this.page!.waitForTimeout(300);
this.log('info', 'Selected track for session', { track: trackSearchTerm });
} else {
this.log('debug', 'Step 13: No track search term provided, skipping track addition');
}
} else {
// Mock mode behavior - add track if config provided
if (config.trackSearch) {
await this.fillField('trackSearch', String(config.trackSearch));
await this.clickAction('confirm');
}
}
// Verify navigation succeeded
if (this.isRealMode()) {
await this.waitForWizardStep('trackOptions');
}
break;
case 14:
// Step 14: Time of Day
if (this.isRealMode()) {
// Auto-skip detection
const actualPage = await this.detectCurrentWizardPage();
const skipOffset = this.synchronizeStepCounter(step, actualPage);
if (skipOffset > 0) {
this.log('info', `Step ${step} was auto-skipped by wizard`, {
actualPage,
skippedSteps: Array.from({ length: skipOffset }, (_, i) => step + i)
});
return { success: true };
}
await this.waitForWizardStep('timeOfDay');
await this.checkWizardDismissed(step);
}
if (config.trackConfig) {
await this.selectDropdown('trackConfig', String(config.trackConfig));
}
await this.clickNextButton('Time of Day');
// Verify navigation succeeded
if (this.isRealMode()) {
await this.waitForWizardStep('timeOfDay');
}
break;
case 15:
// Step 15: Weather
if (this.isRealMode()) {
// Auto-skip detection
const actualPage = await this.detectCurrentWizardPage();
const skipOffset = this.synchronizeStepCounter(step, actualPage);
if (skipOffset > 0) {
this.log('info', `Step ${step} was auto-skipped by wizard`, {
actualPage,
skippedSteps: Array.from({ length: skipOffset }, (_, i) => step + i)
});
return { success: true };
}
await this.waitForWizardStep('weather');
await this.checkWizardDismissed(step);
}
if (config.timeOfDay !== undefined) {
await this.setSlider('timeOfDay', Number(config.timeOfDay));
}
// Dismiss any open datetime pickers before clicking Next
// The Time of Day step has React DateTime pickers that can intercept clicks
if (this.isRealMode()) {
await this.dismissDatetimePickers();
}
await this.clickNextButton('Weather');
break;
case 16:
// Step 16: Race Options
if (this.isRealMode()) {
// Auto-skip detection
const actualPage = await this.detectCurrentWizardPage();
const skipOffset = this.synchronizeStepCounter(step, actualPage);
if (skipOffset > 0) {
this.log('info', `Step ${step} was auto-skipped by wizard`, {
actualPage,
skippedSteps: Array.from({ length: skipOffset }, (_, i) => step + i)
});
return { success: true };
}
await this.waitForWizardStep('raceOptions');
await this.checkWizardDismissed(step);
}
if (config.weatherType && this.isRealMode()) {
// Try to select weather type via Chakra radio button
await this.selectWeatherType(String(config.weatherType));
} else if (config.weatherType && !this.isRealMode()) {
// Mock mode uses dropdown
await this.selectDropdown('weatherType', String(config.weatherType));
}
if (config.temperature !== undefined) {
// Temperature slider - only attempt if element exists
const tempSelector = this.getSliderSelector('temperature');
const tempExists = await this.page!.locator(tempSelector).first().count() > 0;
if (tempExists) {
await this.setSlider('temperature', Number(config.temperature));
} else {
this.log('debug', 'Temperature slider not found, skipping');
}
}
await this.clickNextButton('Track Conditions');
break;
case 17:
// Step 17: Track Conditions (final step with checkout confirmation flow)
if (this.isRealMode()) {
// Auto-skip detection
const actualPage = await this.detectCurrentWizardPage();
const skipOffset = this.synchronizeStepCounter(step, actualPage);
if (skipOffset > 0) {
this.log('info', `Step ${step} was auto-skipped by wizard`, {
actualPage,
skippedSteps: Array.from({ length: skipOffset }, (_, i) => step + i)
});
return { success: true };
}
await this.waitForWizardStep('trackConditions');
await this.checkWizardDismissed(step);
}
if (config.trackState) {
if (this.isRealMode()) {
// Only try to set track state if it's provided, with graceful fallback
try {
const trackStateSelector = this.getDropdownSelector('trackState');
const exists = await this.page!.locator(trackStateSelector).first().count() > 0;
if (exists) {
await this.selectDropdown('trackState', String(config.trackState));
} else {
this.log('debug', 'Track state dropdown not found, skipping');
}
} catch (e) {
this.log('debug', 'Could not set track state (non-critical)', { error: String(e) });
}
} else {
// Mock mode - try select dropdown first, fallback to setting slider/input if no select exists
const trackStateSelector = this.getDropdownSelector('trackState');
const selectorExists = await this.page!.locator(trackStateSelector).first().count().catch(() => 0) > 0;
if (selectorExists) {
await this.selectDropdown('trackState', String(config.trackState));
} else {
// Fallback for mock fixtures: set any slider/input that represents starting track state.
// Map semantic names to approximate numeric slider values used in fixtures.
const valueStr = String(config.trackState);
await this.page!.evaluate((trackStateValue) => {
const map: Record<string, number> = {
'very-low': 10,
'low': 25,
'moderately-low': 40,
'medium': 50,
'moderately-high': 60,
'high': 75,
'very-high': 90
};
const numeric = map[trackStateValue] ?? null;
// Find inputs whose id contains 'starting-track-state' or elements with data-value attr
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('input[id*="starting-track-state"], input[id*="track-state"], input[data-value]'));
if (numeric !== null && inputs.length > 0) {
for (const inp of inputs) {
try {
inp.value = String(numeric);
(inp as any).dataset = (inp as any).dataset || {};
(inp as any).dataset.value = String(numeric);
inp.setAttribute('data-value', String(numeric));
inp.dispatchEvent(new Event('input', { bubbles: true }));
inp.dispatchEvent(new Event('change', { bubbles: true }));
} catch (e) {
// ignore individual failures
}
}
}
}, valueStr);
}
}
}
// Checkout confirmation flow (if callback is set)
if (this.checkoutConfirmationCallback) {
await this.handleCheckoutConfirmation();
}
// Final step - if no callback, don't click next, user must review and confirm
// Return success - step 17 complete
return { success: true };
default:
return { success: false, error: `Unknown step: ${step}` };
}
// Show success on final step
if (step === this.totalSteps && this.isRealMode()) {
await this.showOverlayComplete(true);
}
return { success: true };
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.log('error', 'Step execution failed', { step, error: err.message });
// Show error on overlay
if (this.isRealMode()) {
await this.showOverlayComplete(false, `❌ Failed at step ${step}`);
}
// Save debug info (screenshot and HTML) on failure
const debugPaths = await this.saveDebugInfo(`step-${step}`, err);
// Include debug file paths in error message for easier debugging
let errorMessage = err.message;
if (debugPaths.screenshotPath || debugPaths.htmlPath) {
const paths: string[] = [];
if (debugPaths.screenshotPath) paths.push(`Screenshot: ${debugPaths.screenshotPath}`);
if (debugPaths.htmlPath) paths.push(`HTML: ${debugPaths.htmlPath}`);
errorMessage = `${err.message}\n\nDebug files:\n${paths.join('\n')}`;
}
// Throw error for validation failures (test expectations)
// Return error object for other failures (backward compatibility)
if (errorMessage.includes('validation') || errorMessage.includes('FAILED validation')) {
throw new Error(errorMessage);
}
return { success: false, error: errorMessage };
}
}
/**
* Step-to-Page mapping for wizard auto-skip detection.
* Maps step numbers to their corresponding wizard page names.
*/
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',
};
/**
* Detect which wizard page is currently displayed by checking container existence.
* Returns the page name (e.g., 'cars', 'track') or null if no page is detected.
*
* This method checks each step container from IRACING_SELECTORS.wizard.stepContainers
* and returns the first one that exists in the DOM.
*
* @returns Page name or null if unknown
*/
private async detectCurrentWizardPage(): Promise<string | null> {
if (!this.page) {
return null;
}
try {
// Check each container in stepContainers map
const containers = IRACING_SELECTORS.wizard.stepContainers;
for (const [pageName, selector] of Object.entries(containers)) {
const count = await this.page.locator(selector).count();
if (count > 0) {
this.log('debug', 'Detected wizard page', { pageName, selector });
return pageName;
}
}
// No container found
this.log('debug', 'No wizard page detected');
return null;
} catch (error) {
this.log('debug', 'Error detecting wizard page', { error: String(error) });
return null;
}
}
/**
* Synchronize step counter with actual wizard state.
* Calculates the skip offset when wizard auto-skips steps (e.g., 8→11).
*
* @param expectedStep The step number we're trying to execute
* @param actualPage The actual wizard page detected (from detectCurrentWizardPage)
* @returns Skip offset (0 if no skip, >0 if steps were skipped)
*/
private synchronizeStepCounter(expectedStep: number, actualPage: string | null): number {
if (!actualPage) {
return 0; // Unknown state, no skip
}
// Find which step number corresponds to the actual page
let actualStep: number | null = null;
for (const [step, pageName] of Object.entries(PlaywrightAutomationAdapter.STEP_TO_PAGE_MAP)) {
if (pageName === actualPage) {
actualStep = parseInt(step, 10);
break; // Use first match
}
}
if (actualStep === null) {
return 0; // Unknown page, no skip
}
// Calculate skip offset
const skipOffset = actualStep - expectedStep;
if (skipOffset > 0) {
// Wizard skipped ahead - log warning with skipped step numbers
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;
}
// No skip or backward navigation
return 0;
}
/**
* Save debug information (screenshot and HTML) when a step fails.
* Files are saved to debug-screenshots/ directory with timestamp.
* Returns the paths of saved files for inclusion in error messages.
*
* Error dumps are always kept and not subject to cleanup.
*/
private async saveDebugInfo(stepName: string, error: Error): Promise<{ screenshotPath?: string; htmlPath?: string }> {
if (!this.page) return {};
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const baseName = `debug-error-${stepName}-${timestamp}`;
const debugDir = path.join(process.cwd(), 'debug-screenshots');
const result: { screenshotPath?: string; htmlPath?: string } = {};
try {
await fs.promises.mkdir(debugDir, { recursive: true });
// Save screenshot
const screenshotPath = path.join(debugDir, `${baseName}.png`);
await this.page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshotPath = screenshotPath;
this.log('error', `Error debug screenshot saved: ${screenshotPath}`, { path: screenshotPath, error: error.message });
// Save HTML
const htmlPath = path.join(debugDir, `${baseName}.html`);
const html = await this.page.content();
await fs.promises.writeFile(htmlPath, html);
result.htmlPath = htmlPath;
this.log('error', `Error debug HTML saved: ${htmlPath}`, { path: htmlPath });
} catch (e) {
this.log('warn', 'Failed to save error debug info', { error: String(e) });
}
return result;
}
/**
* Save proactive debug information BEFORE step execution.
* This ensures we always have the most recent state even if the browser is closed via Ctrl+C.
*
* Files are named with "before-step-N" prefix and old snapshots are cleaned up
* to avoid disk bloat (keeps only last MAX_BEFORE_SNAPSHOTS).
*/
private async saveProactiveDebugInfo(step: number): Promise<{ screenshotPath?: string; htmlPath?: string }> {
if (!this.page) return {};
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const baseName = `debug-before-step-${step}-${timestamp}`;
const debugDir = path.join(process.cwd(), 'debug-screenshots');
const result: { screenshotPath?: string; htmlPath?: string } = {};
try {
await fs.promises.mkdir(debugDir, { recursive: true });
// Clean up old "before" snapshots first
await this.cleanupOldBeforeSnapshots(debugDir);
// Save screenshot
const screenshotPath = path.join(debugDir, `${baseName}.png`);
await this.page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshotPath = screenshotPath;
this.log('info', `Pre-step screenshot saved: ${screenshotPath}`, { path: screenshotPath, step });
// Save HTML
const htmlPath = path.join(debugDir, `${baseName}.html`);
const html = await this.page.content();
await fs.promises.writeFile(htmlPath, html);
result.htmlPath = htmlPath;
this.log('info', `Pre-step HTML saved: ${htmlPath}`, { path: htmlPath, step });
} catch (e) {
// Don't fail step execution if debug save fails
this.log('warn', 'Failed to save proactive debug info', { error: String(e), step });
}
return result;
}
/**
* Clean up old "before" debug snapshots to avoid disk bloat.
* Keeps only the last MAX_BEFORE_SNAPSHOTS files (pairs of .png and .html).
*
* Error dumps (prefixed with "debug-error-") are never deleted.
*/
private async cleanupOldBeforeSnapshots(debugDir: string): Promise<void> {
try {
const files = await fs.promises.readdir(debugDir);
// Filter to only "before" snapshot files (not error dumps)
const beforeFiles = files.filter(f => f.startsWith('debug-before-step-'));
// Group by base name (without extension) to handle .png/.html pairs
const baseNames = new Set<string>();
for (const file of beforeFiles) {
// Remove extension to get base name
const baseName = file.replace(/\.(png|html)$/, '');
baseNames.add(baseName);
}
// Sort by timestamp (embedded in filename) - oldest first
const sortedBaseNames = Array.from(baseNames).sort();
// Calculate how many pairs to delete
const pairsToDelete = sortedBaseNames.length - PlaywrightAutomationAdapter.MAX_BEFORE_SNAPSHOTS;
if (pairsToDelete > 0) {
const baseNamesToDelete = sortedBaseNames.slice(0, pairsToDelete);
for (const baseName of baseNamesToDelete) {
// Delete both .png and .html files
const pngPath = path.join(debugDir, `${baseName}.png`);
const htmlPath = path.join(debugDir, `${baseName}.html`);
try {
if (fs.existsSync(pngPath)) {
await fs.promises.unlink(pngPath);
this.log('debug', `Cleaned up old snapshot: ${pngPath}`);
}
} catch {
// Ignore deletion errors
}
try {
if (fs.existsSync(htmlPath)) {
await fs.promises.unlink(htmlPath);
this.log('debug', `Cleaned up old snapshot: ${htmlPath}`);
}
} catch {
// Ignore deletion errors
}
}
this.log('debug', `Cleaned up ${pairsToDelete} old before-step snapshot pairs`);
}
} catch (e) {
// Don't fail if cleanup fails
this.log('debug', 'Failed to cleanup old snapshots', { error: String(e) });
}
}
/**
* Dismiss any visible Chakra UI modal popups or datetime pickers that might block interactions.
* This handles various modal dismiss patterns including close buttons and overlay clicks.
* Also handles React DateTime picker (rdt) popups that can intercept pointer events.
* Optimized for speed - uses instant visibility checks and minimal waits.
*/
private async dismissModals(): Promise<void> {
if (!this.page) return;
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 isModalVisible = await modalContainer.isVisible().catch(() => false);
if (!isModalVisible) {
this.log('debug', 'No modal visible, continuing');
return;
}
this.log('info', 'Modal detected, dismissing immediately');
// Try clicking Continue/Close/OK button with very short timeout
const dismissButton = this.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();
// Instant visibility check
if (await dismissButton.isVisible().catch(() => false)) {
this.log('info', 'Clicking modal dismiss button');
await dismissButton.click({ force: true, timeout: 1000 });
// Brief wait for modal to close (100ms)
await this.page.waitForTimeout(100);
return;
}
// Fallback: try Escape key
this.log('debug', 'No dismiss button found, pressing Escape');
await this.page.keyboard.press('Escape');
await this.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.
*
* Strategy (in order of aggressiveness):
* 1. Use JavaScript to remove 'rdtOpen' class directly (most reliable)
* 2. Click outside the picker on the modal body
* 3. Blur the active element
*/
private async dismissDatetimePickers(): Promise<void> {
if (!this.page) return;
try {
// Check for open datetime pickers (rdt component with class 'rdtOpen')
const initialCount = await this.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: Use JavaScript to directly remove rdtOpen class
// This is the most reliable method as it doesn't require clicking
await this.page.evaluate(() => {
const openPickers = document.querySelectorAll('.rdt.rdtOpen');
openPickers.forEach((picker) => {
picker.classList.remove('rdtOpen');
});
// Also blur any focused inputs to prevent re-opening
const activeEl = document.activeElement as HTMLElement;
if (activeEl && activeEl.blur && activeEl.closest('.rdt')) {
activeEl.blur();
}
});
await this.page.waitForTimeout(50);
// Verify pickers are closed
let stillOpenCount = await this.page.locator('.rdt.rdtOpen').count();
if (stillOpenCount === 0) {
this.log('debug', 'Datetime pickers closed via JavaScript');
return;
}
// 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();
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();
if (await cardHeader.isVisible().catch(() => false)) {
await cardHeader.click({ force: true, timeout: 1000 }).catch(() => {});
await this.page.waitForTimeout(100);
}
}
// Check again
stillOpenCount = await this.page.locator('.rdt.rdtOpen').count();
if (stillOpenCount === 0) {
this.log('debug', 'Datetime pickers closed via click outside');
return;
}
// Strategy 3: Force blur on all datetime inputs
this.log('debug', `${stillOpenCount} picker(s) still open, force blur`);
await this.page.evaluate(() => {
// Blur all inputs inside rdt containers
const rtdInputs = document.querySelectorAll('.rdt input');
rtdInputs.forEach((input) => {
(input as HTMLElement).blur();
});
// Also force remove rdtOpen class again (in case React re-added it)
const openPickers = document.querySelectorAll('.rdt.rdtOpen');
openPickers.forEach((picker) => {
picker.classList.remove('rdtOpen');
// Also hide the picker element directly
const pickerDropdown = picker.querySelector('.rdtPicker') as HTMLElement;
if (pickerDropdown) {
pickerDropdown.style.display = 'none';
}
});
});
await this.page.waitForTimeout(50);
// Final check
const finalCount = await this.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) });
}
}
/**
* 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
*/
private async verifyNotBlockedElement(selector: string): Promise<void> {
if (!this.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 = this.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) => {
// Get only direct text nodes, not text from child elements
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) {
// For elements like <button><span class="icon"></span> Check Out</button>
// We need innerText but should be careful about what we capture
const innerText = await element.innerText().catch(() => '');
// Only use innerText if it's reasonably short (not entire page sections)
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 is our blocked error, re-throw it
if (error instanceof Error && error.message.includes('BLOCKED')) {
throw error;
}
// Otherwise ignore - element might not exist yet, safeClick will handle that
this.log('debug', 'Could not verify element (may not exist yet)', { selector, 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
*/
private async safeClick(selector: string, options?: { timeout?: number; force?: boolean }): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
// 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 {
// On final attempt, use force: true if datetime picker issues detected
const useForce = options?.force || attempt === maxRetries;
await this.page.click(selector, { timeout, force: useForce });
return; // Success
} catch (error) {
// Re-throw blocked errors immediately
if (error instanceof Error && error.message.includes('BLOCKED')) {
throw error;
}
const errorMessage = String(error);
// Check if a modal or datetime picker is intercepting the click
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,
});
// Try dismissing datetime pickers first (common cause of interception)
await this.dismissDatetimePickers();
// Then try dismissing modals
await this.dismissModals();
await this.page.waitForTimeout(200); // Brief wait for DOM to settle
if (attempt === maxRetries) {
// Last attempt already tried with force: true, so if we're here it really failed
this.log('error', 'Max retries reached, click still blocked', { selector });
throw error; // Give up after max retries
}
// Continue to retry
} else {
// Different error, don't retry
throw error;
}
}
}
}
/**
* Select weather type via Chakra UI radio button.
* iRacing's modern UI uses a radio group with options:
* - "Static Weather" (value: 2, checked by default)
* - "Forecasted weather" (value: 1)
* - "Timeline editor" (value: 3)
*
* @param weatherType The weather type to select (e.g., "static", "forecasted", "timeline", or the value)
*/
private async selectWeatherType(weatherType: string): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
try {
this.log('info', 'Selecting weather type via radio button', { weatherType });
// Map common weather type names to selectors
const weatherTypeLower = weatherType.toLowerCase();
let labelSelector: string;
if (weatherTypeLower.includes('static') || weatherType === '2') {
labelSelector = 'label.chakra-radio:has-text("Static Weather")';
} else if (weatherTypeLower.includes('forecast') || weatherType === '1') {
labelSelector = 'label.chakra-radio:has-text("Forecasted weather")';
} else if (weatherTypeLower.includes('timeline') || weatherTypeLower.includes('custom') || weatherType === '3') {
labelSelector = 'label.chakra-radio:has-text("Timeline editor")';
} else {
// Default to static weather
labelSelector = 'label.chakra-radio:has-text("Static Weather")';
this.log('warn', `Unknown weather type "${weatherType}", defaulting to Static Weather`);
}
// Check if radio group exists (weather step might be optional)
const radioGroup = this.page.locator('[role="radiogroup"]').first();
const exists = await radioGroup.count() > 0;
if (!exists) {
this.log('debug', 'Weather radio group not found, step may be optional');
return;
}
// Click the radio button label
const radioLabel = this.page.locator(labelSelector).first();
const isVisible = await radioLabel.isVisible().catch(() => false);
if (isVisible) {
await radioLabel.click({ timeout: IRACING_TIMEOUTS.elementWait });
this.log('info', 'Selected weather type', { weatherType, selector: labelSelector });
} else {
this.log('debug', 'Weather type radio not visible, may already be selected or step is different');
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('warn', 'Could not select weather type (non-critical)', { error: message, weatherType });
// Don't throw - weather type selection is optional
}
}
/**
* Check if the "Add Admin" modal is currently visible.
* This modal appears when the user clicks "Add Admin" on the Admins step.
* @returns true if the admin modal is visible, false otherwise
*/
private async isAdminModalVisible(): Promise<boolean> {
if (!this.page) return false;
try {
// Look for a modal with admin-related content
// The admin modal should have a search input and be separate from the main wizard modal
const adminModalSelector = '#set-admins .modal, .modal:has(input[placeholder*="Search"]):has-text("Admin")';
const isVisible = await this.page.locator(adminModalSelector).first().isVisible().catch(() => false);
return isVisible;
} catch {
return false;
}
}
/**
* Check if the "Add Car" modal is currently visible.
* This modal appears when the user clicks "Add Car" on the Cars step.
* @returns true if the car modal is visible, false otherwise
*/
private async isCarModalVisible(): Promise<boolean> {
if (!this.page) return false;
try {
// Look for a modal with car-related content
// The car modal should have a search input and be part of the set-cars step
const carModalSelector = '#set-cars .modal, .modal:has(input[placeholder*=\"Search\"]):has-text(\"Car\")';
const isVisible = await this.page.locator(carModalSelector).first().isVisible().catch(() => false);
return isVisible;
} catch {
return false;
}
}
/**
* Check if the "Add Track" modal is currently visible.
* This modal appears when the user clicks "Add Track" on the Track step.
* @returns true if the track modal is visible, false otherwise
*/
private async isTrackModalVisible(): Promise<boolean> {
if (!this.page) return false;
try {
// Look for a modal with track-related content
// The track modal should have a search input and be part of the set-track step
const trackModalSelector = '#set-track .modal, .modal:has(input[placeholder*=\"Search\"]):has-text(\"Track\")';
const isVisible = await this.page.locator(trackModalSelector).first().isVisible().catch(() => false);
return isVisible;
} catch {
return false;
}
}
/**
* Click the "Add Car" button to open the Add Car modal.
* This button is located on the Set Cars step (Step 8).
*/
private async clickAddCarButton(): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
const addCarButtonSelector = this.isRealMode()
? IRACING_SELECTORS.steps.addCarButton
: '[data-action="add-car"]';
try {
this.log('info', 'Clicking Add Car button to open modal');
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
await this.page.waitForSelector(addCarButtonSelector, {
state: 'attached',
timeout: this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout,
});
await this.safeClick(addCarButtonSelector);
this.log('info', 'Clicked Add Car button');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Could not click Add Car button', { error: message });
throw new Error(`Failed to click Add Car button: ${message}`);
}
}
/**
* Wait for the Add Car modal to appear.
*/
private async waitForAddCarModal(): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
try {
this.log('debug', 'Waiting for Add Car modal to appear');
// Wait for modal container - use 'attached' because iRacing wizard steps have class="hidden"
const modalSelector = IRACING_SELECTORS.steps.addCarModal;
await this.page.waitForSelector(modalSelector, {
state: 'attached',
timeout: this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout,
});
// Brief pause for modal animation (reduced from 300ms)
await this.page.waitForTimeout(150);
this.log('info', 'Add Car modal is visible');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('warn', 'Add Car modal did not appear', { error: message });
// Don't throw - modal might appear differently in real iRacing
}
}
/**
* Click the "Add Track" / "Select Track" button to open the Add Track modal.
* This button is located on the Set Track step (Step 11).
*/
private async clickAddTrackButton(): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
const addTrackButtonSelector = IRACING_SELECTORS.steps.addTrackButton;
try {
this.log('info', 'Clicking Add Track button to open modal');
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
await this.page.waitForSelector(addTrackButtonSelector, {
state: 'attached',
timeout: this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout,
});
await this.safeClick(addTrackButtonSelector);
this.log('info', 'Clicked Add Track button');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Could not click Add Track button', { error: message });
throw new Error(`Failed to click Add Track button: ${message}`);
}
}
/**
* Wait for the Add Track modal to appear.
*/
private async waitForAddTrackModal(): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
try {
this.log('debug', 'Waiting for Add Track modal to appear');
// Wait for modal container - use 'attached' because iRacing wizard steps have class="hidden"
const modalSelector = IRACING_SELECTORS.steps.addTrackModal;
await this.page.waitForSelector(modalSelector, {
state: 'attached',
timeout: this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout,
});
// Brief pause for modal animation (reduced from 300ms)
await this.page.waitForTimeout(150);
this.log('info', 'Add Track modal is visible');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('warn', 'Add Track modal did not appear', { error: message });
// Don't throw - modal might appear differently in real iRacing
}
}
/**
* Select the first search result in the current modal by clicking its "Select" button.
* In iRacing's Add Car/Track modals, search results are displayed in a table,
* and each row has a "Select" button (a.btn.btn-block.btn-primary.btn-xs).
*
* Two button patterns exist:
* 1. Direct select (single-config tracks): a.btn.btn-primary.btn-xs:not(.dropdown-toggle)
* 2. Dropdown (multi-config tracks): a.btn.btn-primary.btn-xs.dropdown-toggle → opens menu → click .dropdown-item
*
* Clicking "Select" immediately adds the item - there is no confirm step.
*/
private async selectFirstSearchResult(): Promise<void> {
if (!this.page) {
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');
return;
}
// Fallback: dropdown toggle pattern
const dropdownSelector = '.modal table a.btn.btn-primary.btn-xs.dropdown-toggle';
const dropdownButton = this.page.locator(dropdownSelector).first();
if (await dropdownButton.count() > 0 && await dropdownButton.isVisible()) {
// Click dropdown to open menu
await this.safeClick(dropdownSelector, { timeout: IRACING_TIMEOUTS.elementWait });
this.log('debug', 'Clicked dropdown toggle, waiting for menu');
// Wait for dropdown menu to appear
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';
await this.page.waitForTimeout(200);
await this.safeClick(itemSelector, { timeout: IRACING_TIMEOUTS.elementWait });
this.log('info', 'Clicked first dropdown item to select track config');
return;
}
// If neither found, throw error
throw new Error('No Select button found in modal table');
}
// NOTE: clickCarModalConfirm() and clickTrackModalConfirm() have been removed.
// The Add Car/Track modals use a table with "Select" buttons that immediately add the item.
// There is no separate confirm step - clicking "Select" closes the modal and adds the car/track.
// The selectFirstSearchResult() method now handles clicking the "Select" button directly.
/**
* Click the confirm/select button in the "Add Admin" modal.
* Uses a specific selector that avoids the checkout button.
*/
private async clickAdminModalConfirm(): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
// Use a selector specific to the admin modal, NOT the main wizard modal footer
// The admin modal confirm button should be inside the admin modal content
const adminConfirmSelector = '#set-admins .modal .btn-primary, #set-admins .modal button:has-text("Add"), #set-admins .modal button:has-text("Select")';
try {
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
await this.page.waitForSelector(adminConfirmSelector, {
state: 'attached',
timeout: IRACING_TIMEOUTS.elementWait,
});
await this.safeClick(adminConfirmSelector, { timeout: IRACING_TIMEOUTS.elementWait });
this.log('info', 'Clicked admin modal confirm button');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('warn', 'Could not click admin modal confirm button', { error: message });
throw new Error(`Failed to confirm admin selection: ${message}`);
}
}
/**
* Click the "New Race" button in the modal that appears after clicking "Create a Race".
* This modal asks whether to use "Last Settings" or "New Race".
*/
private async clickNewRaceInModal(): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
try {
this.log('info', 'Waiting for Create Race modal to appear');
// Wait for the modal - use 'attached' because iRacing elements may have class="hidden"
const modalSelector = IRACING_SELECTORS.hostedRacing.createRaceModal;
await this.page.waitForSelector(modalSelector, {
state: 'attached',
timeout: IRACING_TIMEOUTS.elementWait,
});
this.log('info', 'Create Race modal attached, clicking New Race button');
// Click the "New Race" button - use 'attached' for consistency
const newRaceSelector = IRACING_SELECTORS.hostedRacing.newRaceButton;
await this.page.waitForSelector(newRaceSelector, {
state: 'attached',
timeout: IRACING_TIMEOUTS.elementWait,
});
await this.safeClick(newRaceSelector, { timeout: IRACING_TIMEOUTS.elementWait });
this.log('info', 'Clicked New Race button, waiting for form to load');
// Wait a moment for the form to load
await this.page.waitForTimeout(500);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed to click New Race in modal', { error: message });
throw new Error(`Failed to click New Race button: ${message}`);
}
}
/**
* Handle login for real iRacing website.
* First checks if user is already authenticated - if so, navigates directly to hosted sessions.
*/
private async injectCookiesBeforeNavigation(targetUrl: string): Promise<Result<void>> {
if (!this.persistentContext && !this.context) {
return Result.err(new Error('No browser context available'));
}
try {
// Read cookies from store
const state = await this.cookieStore.read();
if (!state || state.cookies.length === 0) {
return Result.err(new Error('No cookies found in session store'));
}
// Get only cookies that are valid for target URL
// This filters out cookies from other domains (e.g., oauth.iracing.com, members.iracing.com)
// and only injects cookies that match the target domain
const validCookies = this.cookieStore.getValidCookiesForUrl(targetUrl);
if (validCookies.length === 0) {
this.log('warn', 'No valid cookies found for target URL', {
targetUrl,
totalCookies: state.cookies.length,
});
return Result.err(new Error('No valid cookies found for target URL'));
}
// Inject cookies into context BEFORE navigation
const context = this.persistentContext || this.context;
await context!.addCookies(validCookies);
this.log('info', 'Cookies injected successfully', {
count: validCookies.length,
targetUrl,
cookieNames: validCookies.map((c) => c.name),
});
return Result.ok(undefined);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Result.err(new Error(`Cookie injection failed: ${message}`));
}
}
async verifyPageAuthentication(): Promise<Result<BrowserAuthenticationState>> {
if (!this.page) {
return Result.err(new Error('Browser not connected'));
}
try {
// Check current URL - if we're on an authenticated page path, we're authenticated
const url = this.page.url();
const isOnAuthenticatedPath =
url.includes('/web/racing/hosted') ||
url.includes('/membersite/member') ||
url.includes('/members-ng.iracing.com');
const isOnLoginPath =
url.includes('/login') ||
url.includes('oauth.iracing.com');
// Check for login UI indicators
const guard = new AuthenticationGuard(this.page, this.logger);
const hasLoginUI = await guard.checkForLoginUI();
// 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"]',
// User menu/profile indicators (present on ALL authenticated pages)
'[aria-label*="user menu" i]',
'[aria-label*="account menu" i]',
'.user-menu',
'.account-menu',
// iRacing-specific: members navigation
'nav a[href*="/membersite"]',
'nav a[href*="/members"]',
];
let hasAuthUI = false;
for (const selector of authSelectors) {
try {
const element = this.page.locator(selector).first();
const isVisible = await element.isVisible().catch(() => false);
if (isVisible) {
this.log('info', 'Authenticated UI detected', { selector });
hasAuthUI = true;
break;
}
} catch {
// Selector not found, continue
}
}
// Check cookies
const cookieResult = await this.checkSession();
const cookiesValid =
cookieResult.isOk() &&
cookieResult.unwrap() === AuthenticationState.AUTHENTICATED;
// Determine page authentication state
// Priority order:
// 1. If on authenticated path and cookies valid, we're authenticated
// 2. If we see authenticated UI, we're authenticated
// 3. If not on login path and no login UI, we're authenticated
const pageAuthenticated =
(isOnAuthenticatedPath && !isOnLoginPath && cookiesValid) ||
hasAuthUI ||
(!hasLoginUI && !isOnLoginPath);
this.log('debug', 'Page authentication check', {
url,
isOnAuthenticatedPath,
isOnLoginPath,
hasLoginUI,
hasAuthUI,
cookiesValid,
pageAuthenticated,
});
return Result.ok(new BrowserAuthenticationState(cookiesValid, pageAuthenticated));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Result.err(new Error(`Page verification failed: ${message}`));
}
}
/**
* Handle login for real iRacing website.
* First checks if user is already authenticated - if so, navigates directly to hosted sessions.
* Otherwise navigates to login page and waits for user to complete manual login.
*/
private async handleLogin(): Promise<AutomationResult> {
try {
// Check session cookies FIRST before launching browser
const sessionResult = await this.checkSession();
if (
sessionResult.isOk() &&
sessionResult.unwrap() === AuthenticationState.AUTHENTICATED
) {
// Valid cookies exist - use configured browser mode (headless/headed)
this.log('info', 'Session cookies found, launching in configured browser mode');
await this.ensureBrowserContext(false); // Use configured mode
if (!this.page) {
return { success: false, error: 'Browser not connected' };
}
// Inject cookies BEFORE navigation
const injectResult = await this.injectCookiesBeforeNavigation(
IRACING_URLS.hostedSessions
);
if (injectResult.isErr()) {
this.log('warn', 'Cookie injection failed, switching to manual login', {
error: injectResult.error.message,
});
// Fall through to manual login flow below
} else {
// Navigate with cookies injected
await this.page.goto(IRACING_URLS.hostedSessions, {
waitUntil: 'domcontentloaded',
timeout: IRACING_TIMEOUTS.navigation,
});
// Verify page shows authenticated state
const verifyResult = await this.verifyPageAuthentication();
if (verifyResult.isOk()) {
const browserState = verifyResult.unwrap();
if (browserState.isFullyAuthenticated()) {
this.log('info', 'Authentication verified successfully');
return { success: true };
} else {
this.log('warn', 'Page shows unauthenticated state despite cookies');
// Fall through to manual login flow below
}
}
}
}
// No valid cookies or cookie injection failed - need manual login
// Close existing browser if running in headless mode
// Must restart in headed mode so user can see and interact with login page
if (this.actualBrowserMode === 'headless' && (this.browser || this.persistentContext)) {
this.log('info', '[Auth] Closing headless browser to restart in headed mode for manual login');
await this.closeBrowserContext();
}
// Ensure browser context is ready, forcing headed mode for manual login
await this.ensureBrowserContext(true);
if (!this.page) {
return { success: false, error: 'Browser not connected after restart' };
}
// Not authenticated - proceed with login flow
this.log('info', 'Not authenticated, navigating to iRacing login page');
await this.page.goto(IRACING_URLS.login, {
waitUntil: 'domcontentloaded',
timeout: IRACING_TIMEOUTS.navigation,
});
this.log('info', 'Waiting for user to complete login (max 2 minutes)...');
// Wait for navigation to hosted sessions page
await this.page.waitForURL('**/hostedsessions**', {
timeout: IRACING_TIMEOUTS.loginWait,
});
this.log('info', 'Login successful, now on hosted sessions page');
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Login failed or timed out', { error: message });
return { success: false, error: `Login failed: ${message}` };
}
}
async waitForStep(stepNumber: number): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
// In mock mode, update the data-step attribute on body to reflect current step
// This is needed for getCurrentStep() to work correctly in tests
if (!this.isRealMode()) {
await this.page.evaluate((step) => {
document.body.setAttribute('data-step', String(step));
}, stepNumber);
}
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
await this.page.waitForSelector(`[data-step="${stepNumber}"]`, {
state: 'attached',
timeout,
});
}
/**
* Wait for a specific wizard step to be visible in real mode.
* Uses the step container IDs from IRACING_SELECTORS.wizard.stepContainers
*/
private async waitForWizardStep(stepName: keyof typeof IRACING_SELECTORS.wizard.stepContainers): Promise<void> {
if (!this.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 });
// Use 'attached' instead of 'visible' because iRacing wizard steps are marked as
// 'active hidden' in the DOM - they exist but are hidden via CSS class
await this.page.waitForSelector(containerSelector, {
state: 'attached',
timeout: 15000,
});
// Brief pause to ensure DOM is settled
await this.page.waitForTimeout(100);
} catch (error) {
this.log('warn', `Wizard step not attached: ${stepName}`, { error: String(error) });
// Don't throw - step might be combined with another or skipped
}
}
/**
* Fill a form field with fallback selector support.
* Tries the primary selector first, then falls back to alternative selectors.
* This is needed because iRacing's form structure can vary slightly.
*/
private async fillFieldWithFallback(fieldName: string, value: string): Promise<FormFillResult> {
if (!this.page) {
return { success: false, fieldName, value, error: 'Browser not connected' };
}
const selector = this.getFieldSelector(fieldName);
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
// Split combined selectors and try each one
const selectors = selector.split(', ').map(s => s.trim());
for (const sel of selectors) {
try {
this.log('debug', `Trying selector for ${fieldName}`, { selector: sel });
// Check if element exists and is visible
const element = this.page.locator(sel).first();
const isVisible = await element.isVisible().catch(() => false);
if (isVisible) {
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
await element.waitFor({ state: 'attached', timeout });
await element.fill(value);
this.log('info', `Successfully filled ${fieldName}`, { selector: sel, value });
return { success: true, fieldName, value };
}
} catch (error) {
this.log('debug', `Selector failed for ${fieldName}`, { selector: sel, error: String(error) });
}
}
// If none worked, try the original combined selector (Playwright handles comma-separated)
try {
this.log('debug', `Trying combined selector for ${fieldName}`, { selector });
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
await this.page.waitForSelector(selector, { state: 'attached', timeout });
await this.page.fill(selector, value);
return { success: true, fieldName, value };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', `Failed to fill ${fieldName}`, { selector, error: message });
return { success: false, fieldName, value, error: message };
}
}
/**
* Click the "Next" button in the wizard footer.
* In real iRacing, the next button text shows the next step name (e.g., "Server Details").
* @param nextStepName The name of the next step (for logging and fallback)
*/
private async clickNextButton(nextStepName: string): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
if (!this.isRealMode()) {
// Mock mode uses data-action="next"
await this.clickAction('next');
return;
}
const timeout = IRACING_TIMEOUTS.elementWait;
// Primary: Look for the next button with caret icon (it points to next step)
const nextButtonSelector = IRACING_SELECTORS.wizard.nextButton;
// Fallback: Look for button with the next step name
const fallbackSelector = `.wizard-footer a.btn:has-text("${nextStepName}")`;
try {
// Try primary selector first
this.log('debug', 'Looking for next button', { selector: nextButtonSelector });
const nextButton = this.page.locator(nextButtonSelector).first();
const isVisible = await nextButton.isVisible().catch(() => false);
if (isVisible) {
await this.safeClick(nextButtonSelector, { timeout });
this.log('info', `Clicked next button to ${nextStepName}`);
return;
}
// Try fallback with step name
this.log('debug', 'Trying fallback next button', { selector: fallbackSelector });
const fallback = this.page.locator(fallbackSelector).first();
const fallbackVisible = await fallback.isVisible().catch(() => false);
if (fallbackVisible) {
await this.safeClick(fallbackSelector, { timeout });
this.log('info', `Clicked next button (fallback) to ${nextStepName}`);
return;
}
// Last resort: any non-disabled button in wizard footer
const lastResort = '.wizard-footer a.btn:not(.disabled):last-child';
await this.safeClick(lastResort, { timeout });
this.log('info', `Clicked next button (last resort) to ${nextStepName}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', `Failed to click next button to ${nextStepName}`, { error: message });
throw new Error(`Failed to navigate to ${nextStepName}: ${message}`);
}
}
async clickAction(action: string): Promise<ClickResult> {
if (!this.page) {
return { success: false, error: 'Browser not connected' };
}
const selector = this.getActionSelector(action);
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
await this.page.waitForSelector(selector, { state: 'attached', timeout });
await this.safeClick(selector, { timeout });
return { success: true };
}
async fillField(fieldName: string, value: string): Promise<FormFillResult> {
if (!this.page) {
return { success: false, fieldName, value, error: 'Browser not connected' };
}
const selector = this.getFieldSelector(fieldName);
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
await this.page.waitForSelector(selector, { state: 'attached', timeout });
await this.page.fill(selector, value);
return { success: true, fieldName, value };
}
async selectDropdown(name: string, value: string): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
const selector = this.getDropdownSelector(name);
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
// on the container - elements are in DOM but not visible via CSS
await this.page.waitForSelector(selector, { state: 'attached', timeout });
await this.page.selectOption(selector, value);
}
private getDropdownSelector(name: string): string {
const dropdownMap: Record<string, string> = {
region: IRACING_SELECTORS.steps.region,
trackConfig: IRACING_SELECTORS.steps.trackConfig,
weatherType: IRACING_SELECTORS.steps.weatherType,
trackState: IRACING_SELECTORS.steps.trackState,
};
return dropdownMap[name] || IRACING_SELECTORS.fields.select;
}
async setToggle(name: string, checked: boolean): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
const selector = this.getToggleSelector(name);
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
// on the container - elements are in DOM but not visible via CSS
await this.page.waitForSelector(selector, { state: 'attached', timeout });
const isChecked = await this.page.isChecked(selector);
if (isChecked !== checked) {
await this.safeClick(selector, { timeout });
}
}
private getToggleSelector(name: string): string {
const toggleMap: Record<string, string> = {
startNow: IRACING_SELECTORS.steps.startNow,
rollingStart: IRACING_SELECTORS.steps.rollingStart,
};
return toggleMap[name] || IRACING_SELECTORS.fields.checkbox;
}
async setSlider(name: string, value: number): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
const selector = this.getSliderSelector(name);
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
// on the container - elements are in DOM but not visible via CSS
await this.page.waitForSelector(selector, { state: 'attached', timeout });
await this.page.fill(selector, String(value));
}
private getSliderSelector(name: string): string {
const sliderMap: Record<string, string> = {
practice: IRACING_SELECTORS.steps.practice,
qualify: IRACING_SELECTORS.steps.qualify,
race: IRACING_SELECTORS.steps.race,
timeOfDay: IRACING_SELECTORS.steps.timeOfDay,
temperature: IRACING_SELECTORS.steps.temperature,
};
return sliderMap[name] || IRACING_SELECTORS.fields.slider;
}
async waitForModal(): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
const selector = IRACING_SELECTORS.wizard.modal;
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
await this.page.waitForSelector(selector, {
state: 'attached',
timeout,
});
}
async selectListItem(itemId: string): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
const selector = `[data-item="${itemId}"], button:has-text("${itemId}")`;
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
await this.page.waitForSelector(selector, { state: 'attached', timeout });
await this.safeClick(selector, { timeout });
}
async openModalTrigger(type: string): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
const selector = `button:has-text("${type}"), [aria-label*="${type}" i]`;
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
// Use 'attached' instead of 'visible' because iRacing wizard steps have class="hidden"
await this.page.waitForSelector(selector, { state: 'attached', timeout });
await this.safeClick(selector, { timeout });
}
async getCurrentStep(): Promise<number | null> {
if (!this.page) {
return null;
}
if (this.isRealMode()) {
// In real mode, we can't reliably determine step from DOM
// Return null and let higher-level logic track step state
return null;
}
const stepAttr = await this.page.getAttribute('body', 'data-step');
return stepAttr ? parseInt(stepAttr, 10) : null;
}
getMode(): AutomationAdapterMode {
return this.config.mode;
}
getPage(): Page | null {
return this.page;
}
// ===== IAuthenticationService Implementation =====
/**
* Check if user has a valid session by reading the cookie store JSON file.
* NO BROWSER LAUNCH - just reads the persisted storage state.
*/
async checkSession(): Promise<Result<AuthenticationState>> {
try {
this.log('info', 'Checking iRacing session from cookie store');
const state = await this.cookieStore.read();
if (!state) {
this.authState = AuthenticationState.UNKNOWN;
this.log('info', 'No session state file found');
return Result.ok(this.authState);
}
this.authState = this.cookieStore.validateCookies(state.cookies);
this.log('info', 'Session check complete', { state: this.authState });
return Result.ok(this.authState);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Session check failed', { error: message });
return Result.err(new Error(`Session check failed: ${message}`));
}
}
/**
* Get the iRacing login URL.
* Used by the main process to open in the system's default browser.
*/
getLoginUrl(): string {
return IRACING_URLS.login;
}
/**
* Wait for login success by monitoring the page URL.
* Login is successful when:
* - URL contains 'members.iracing.com' AND
* - URL does NOT contain 'oauth.iracing.com' (login page)
*
* @param timeoutMs Maximum time to wait for login (default: 5 minutes)
* @returns true if login was detected, false if timeout
*/
private async waitForLoginSuccess(timeoutMs = 300000): Promise<boolean> {
if (!this.page) {
return false;
}
const startTime = Date.now();
this.log('info', 'Waiting for login success', { timeoutMs });
while (Date.now() - startTime < timeoutMs) {
try {
const url = this.page.url();
// Success: User is on members site (not oauth login page)
// Check for various success indicators:
// - URL contains members.iracing.com but not oauth.iracing.com
// - Or URL is the hosted sessions page
const isOnMembersSite = url.includes('members.iracing.com');
const isOnLoginPage = url.includes('oauth.iracing.com') ||
url.includes('/membersite/login') ||
url.includes('/login.jsp');
if (isOnMembersSite && !isOnLoginPage) {
this.log('info', 'Login success detected', { url });
return true;
}
// Check if page is closed (user closed the browser)
if (this.page.isClosed()) {
this.log('warn', 'Browser page was closed by user');
return false;
}
// Wait before checking again
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
// Page might be navigating or closed
const message = error instanceof Error ? error.message : String(error);
this.log('debug', 'Error checking URL during login wait', { error: message });
// If we can't access the page, it might be closed
if (!this.page || this.page.isClosed()) {
return false;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
}
this.log('warn', 'Login wait timed out');
return false;
}
/**
* Initiate login by opening the Playwright browser to the iRacing login page.
* This uses the same browser context that will be used for automation,
* ensuring cookies are shared.
*
* The method will:
* 1. Launch browser and navigate to login page
* 2. Wait for user to complete login (auto-detect via URL)
* 3. Save session state on success
* 4. Close browser automatically
*
* @returns Result with void on success, Error on failure/timeout
*/
async initiateLogin(): Promise<Result<void>> {
try {
this.log('info', 'Opening iRacing login in Playwright browser');
// Connect to launch the browser (this uses the persistent context)
const connectResult = await this.connect();
if (!connectResult.success) {
return Result.err(new Error(connectResult.error || 'Failed to connect browser'));
}
if (!this.page) {
return Result.err(new Error('No page available after connect'));
}
// Navigate to iRacing login page
await this.page.goto(IRACING_URLS.login, {
waitUntil: 'domcontentloaded',
timeout: IRACING_TIMEOUTS.navigation,
});
this.log('info', 'Playwright browser opened to iRacing login page, waiting for login...');
this.authState = AuthenticationState.UNKNOWN;
// Wait for login success (auto-detect)
const loginSuccess = await this.waitForLoginSuccess();
if (loginSuccess) {
// Save session state
this.log('info', 'Login detected, saving session state');
await this.saveSessionState();
// Verify cookies were saved correctly
const state = await this.cookieStore.read();
if (state && this.cookieStore.validateCookies(state.cookies) === AuthenticationState.AUTHENTICATED) {
this.authState = AuthenticationState.AUTHENTICATED;
this.log('info', 'Session saved and validated successfully');
} else {
this.authState = AuthenticationState.UNKNOWN;
this.log('warn', 'Session saved but validation unclear');
}
// Close browser
this.log('info', 'Closing browser after successful login');
await this.disconnect();
return Result.ok(undefined);
}
// Login failed or timed out
this.log('warn', 'Login was not completed');
await this.disconnect();
return Result.err(new Error('Login timeout - please try again'));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed during login process', { error: message });
// Try to clean up
try {
await this.disconnect();
} catch {
// Ignore cleanup errors
}
return Result.err(error instanceof Error ? error : new Error(message));
}
}
/**
* Called when user confirms they have completed login in the Playwright browser.
* Saves the session state to the cookie store for future auth checks.
*/
async confirmLoginComplete(): Promise<Result<void>> {
try {
this.log('info', 'User confirmed login complete');
// Save session state to cookie store
await this.saveSessionState();
// Verify cookies were saved correctly
const state = await this.cookieStore.read();
if (state && this.cookieStore.validateCookies(state.cookies) === AuthenticationState.AUTHENTICATED) {
this.authState = AuthenticationState.AUTHENTICATED;
this.log('info', 'Login confirmed and session saved successfully');
} else {
this.authState = AuthenticationState.UNKNOWN;
this.log('warn', 'Login confirmation received but session state unclear');
}
return Result.ok(undefined);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed to confirm login', { error: message });
return Result.err(error instanceof Error ? error : new Error(message));
}
}
/**
* Save the current browser context's storage state to the cookie store.
* This persists cookies for future auth checks without needing to launch the browser.
*/
async saveSessionState(): Promise<void> {
if (!this.persistentContext && !this.context) {
this.log('warn', 'No browser context available to save session state');
return;
}
try {
const context = this.persistentContext || this.context;
if (!context) {
return;
}
const storageState = await context.storageState();
await this.cookieStore.write(storageState);
this.log('info', 'Session state saved to cookie store');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed to save session state', { error: message });
throw error;
}
}
/**
* Clear the persistent session by removing stored browser data.
*/
async clearSession(): Promise<Result<void>> {
try {
this.log('info', 'Clearing session');
// Delete cookie store file first
await this.cookieStore.delete();
this.log('debug', 'Cookie store deleted');
// If we have a persistent context, close it first
if (this.persistentContext) {
await this.persistentContext.close();
this.persistentContext = null;
this.page = null;
this.connected = false;
}
// Delete the user data directory if it exists
if (this.config.userDataDir && fs.existsSync(this.config.userDataDir)) {
this.log('debug', 'Removing user data directory', { path: this.config.userDataDir });
fs.rmSync(this.config.userDataDir, { recursive: true, force: true });
}
this.authState = AuthenticationState.LOGGED_OUT;
this.log('info', 'Session cleared successfully');
return Result.ok(undefined);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed to clear session', { error: message });
return Result.err(new Error(`Failed to clear session: ${message}`));
}
}
/**
* Get current authentication state (cached, no network request).
*/
getState(): AuthenticationState {
return this.authState;
}
/**
* Validate session with server-side check.
* Makes a lightweight HTTP request to verify cookies are still valid on the server.
*/
async validateServerSide(): Promise<Result<boolean>> {
try {
this.log('info', 'Performing server-side session validation');
if (!this.persistentContext && !this.context) {
return Result.err(new Error('No browser context available'));
}
const context = this.persistentContext || this.context;
if (!context) {
return Result.err(new Error('Browser context is null'));
}
// Create a temporary page for validation
const page = await context.newPage();
try {
// Navigate to a protected iRacing page with a short timeout
const response = await page.goto(IRACING_URLS.hostedSessions, {
waitUntil: 'domcontentloaded',
timeout: 10000,
});
if (!response) {
return Result.ok(false);
}
// Check if we were redirected to login page
const finalUrl = page.url();
const isOnLoginPage = finalUrl.includes('oauth.iracing.com') ||
finalUrl.includes('/membersite/login') ||
finalUrl.includes('/login.jsp');
const isValid = !isOnLoginPage;
this.log('info', 'Server-side validation complete', { isValid, finalUrl });
return Result.ok(isValid);
} finally {
await page.close();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('warn', 'Server-side validation failed', { error: message });
return Result.err(new Error(`Server validation failed: ${message}`));
}
}
/**
* Refresh session state from cookie store.
* Re-reads cookies and updates internal state without server validation.
*/
async refreshSession(): Promise<Result<void>> {
try {
this.log('info', 'Refreshing session from cookie store');
const state = await this.cookieStore.read();
if (!state) {
this.authState = AuthenticationState.UNKNOWN;
return Result.ok(undefined);
}
this.authState = this.cookieStore.validateCookies(state.cookies);
this.log('info', 'Session refreshed', { state: this.authState });
return Result.ok(undefined);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Session refresh failed', { error: message });
return Result.err(new Error(`Session refresh failed: ${message}`));
}
}
/**
* Get session expiry date from cookie store.
*/
async getSessionExpiry(): Promise<Result<Date | null>> {
try {
const expiry = await this.cookieStore.getSessionExpiry();
return Result.ok(expiry);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed to get session expiry', { error: message });
return Result.err(new Error(`Failed to get session expiry: ${message}`));
}
}
/**
* Get the user data directory path for persistent sessions.
*/
getUserDataDir(): string {
return this.config.userDataDir;
}
/**
* Get the browser mode (headed or headless).
*/
getBrowserMode(): BrowserMode {
return this.actualBrowserMode;
}
/**
* Get the source of the browser mode configuration.
*/
getBrowserModeSource(): 'env' | 'file' | 'default' {
return this.browserModeSource;
}
/**
* Set the checkout confirmation callback.
* This callback is invoked during step 17 before clicking the checkout button,
* allowing the UI to request user confirmation with the extracted price and state.
*
* @param callback Function that receives price and state, returns confirmation decision
*/
setCheckoutConfirmationCallback(
callback?: (price: CheckoutPrice, state: CheckoutState) => Promise<CheckoutConfirmation>
): void {
this.checkoutConfirmationCallback = callback;
}
// ===== Overlay Methods =====
/**
* Inject the automation overlay into the current page.
* The overlay shows the current step, progress, and personality messages.
* Safe to call multiple times - will only inject once per page.
*/
async injectOverlay(): Promise<void> {
if (!this.page || this.overlayInjected) {
return;
}
try {
this.log('info', 'Injecting automation overlay');
// Inject CSS
await this.page.addStyleTag({ content: OVERLAY_CSS });
// Inject HTML
await this.page.evaluate((html) => {
const existing = document.getElementById('gridpilot-overlay');
if (existing) {
existing.remove();
}
const container = document.createElement('div');
container.innerHTML = html;
const overlay = container.firstElementChild;
if (overlay) {
document.body.appendChild(overlay);
}
}, OVERLAY_HTML);
this.overlayInjected = true;
this.log('info', 'Automation overlay injected successfully');
// Setup close listeners (ESC key, modal dismissal)
await this.setupCloseListeners();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('warn', 'Failed to inject overlay', { error: message });
}
}
/**
* Update the overlay with current step information and progress.
* @param step Current step number (1-18)
* @param customMessage Optional custom action message
*/
async updateOverlay(step: number, customMessage?: string): Promise<void> {
if (!this.page) {
return;
}
try {
// Check if overlay actually exists in DOM (handles SPA navigation)
const overlayExists = await this.page.locator('#gridpilot-overlay').count() > 0;
if (!overlayExists) {
this.overlayInjected = false; // Reset flag if overlay gone
}
// Ensure overlay is injected
if (!this.overlayInjected) {
await this.injectOverlay();
}
const actionMessage = customMessage || OVERLAY_STEP_MESSAGES[step] || `Processing step ${step}...`;
const progress = Math.round((step / this.totalSteps) * 100);
const personality = OVERLAY_PERSONALITY_MESSAGES[Math.floor(Math.random() * OVERLAY_PERSONALITY_MESSAGES.length)];
await this.page.evaluate(({ actionMsg, progressPct, stepNum, totalSteps, personalityMsg }) => {
const actionEl = document.getElementById('gridpilot-action');
const progressEl = document.getElementById('gridpilot-progress');
const stepTextEl = document.getElementById('gridpilot-step-text');
const stepCountEl = document.getElementById('gridpilot-step-count');
const personalityEl = document.getElementById('gridpilot-personality');
if (actionEl) actionEl.textContent = actionMsg;
if (progressEl) progressEl.style.width = `${progressPct}%`;
if (stepTextEl) stepTextEl.textContent = actionMsg;
if (stepCountEl) stepCountEl.textContent = `Step ${stepNum} of ${totalSteps}`;
if (personalityEl) personalityEl.textContent = personalityMsg;
}, {
actionMsg: actionMessage,
progressPct: progress,
stepNum: step,
totalSteps: this.totalSteps,
personalityMsg: personality
});
this.log('debug', 'Overlay updated', { step, progress, actionMessage });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('debug', 'Failed to update overlay', { error: message });
}
}
/**
* Show a completion message on the overlay.
* @param success Whether the automation completed successfully
* @param message Optional custom completion message
*/
async showOverlayComplete(success: boolean, message?: string): Promise<void> {
if (!this.page) {
return;
}
// Ensure overlay is injected before trying to update it
if (!this.overlayInjected) {
await this.injectOverlay();
}
try {
const actionMessage = message || (success ? '✅ Setup complete! Review settings and click "Create Race" to confirm.' : '❌ Setup encountered an issue');
const emoji = success ? '🏆' : '⚠️';
const personality = success
? '👆 Check everything looks right, then create your race!'
: '🔧 Check the error and try again.';
await this.page.evaluate(({ actionMsg, emoji, personalityMsg, success }) => {
const actionEl = document.getElementById('gridpilot-action');
const progressEl = document.getElementById('gridpilot-progress');
const stepCountEl = document.getElementById('gridpilot-step-count');
const personalityEl = document.getElementById('gridpilot-personality');
const spinnerEl = document.querySelector('.gridpilot-spinner') as HTMLElement;
const logoEl = document.querySelector('.gridpilot-logo') as HTMLElement;
if (actionEl) actionEl.textContent = actionMsg;
if (progressEl) progressEl.style.width = success ? '100%' : progressEl.style.width;
if (stepCountEl) stepCountEl.textContent = success ? 'Complete!' : 'Stopped';
if (personalityEl) personalityEl.textContent = personalityMsg;
if (spinnerEl) spinnerEl.style.display = 'none';
if (logoEl) logoEl.textContent = emoji;
}, { actionMsg: actionMessage, emoji, personalityMsg: personality, success });
this.log('info', 'Overlay completion shown', { success, message: actionMessage });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('debug', 'Failed to show overlay completion', { error: message });
}
}
/**
* Remove the overlay from the page.
* Called when automation is complete or cancelled.
*/
async removeOverlay(): Promise<void> {
if (!this.page || !this.overlayInjected) {
return;
}
try {
await this.page.evaluate(() => {
const overlay = document.getElementById('gridpilot-overlay');
if (overlay) {
overlay.style.animation = 'none';
overlay.style.transition = 'transform 0.3s ease-in, opacity 0.3s ease-in';
overlay.style.transform = 'translateX(100%)';
overlay.style.opacity = '0';
setTimeout(() => overlay.remove(), 300);
}
});
this.overlayInjected = false;
this.log('info', 'Automation overlay removed');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('debug', 'Failed to remove overlay', { error: message });
}
}
/**
* Reset overlay state when navigating to a new page.
* Should be called after page navigation to ensure overlay can be re-injected.
*/
resetOverlayState(): void {
this.overlayInjected = false;
}
// ===== Pause Functionality =====
/**
* Check if automation is currently paused in the browser.
* Reads the window.__gridpilot_paused variable set by the overlay button.
* @returns true if paused, false otherwise
*/
private async isPausedInBrowser(): Promise<boolean> {
if (!this.page) {
return false;
}
try {
return await this.page.evaluate(() => (window as unknown as { __gridpilot_paused?: boolean }).__gridpilot_paused === true);
} catch {
// If we can't check (page navigating, etc.), assume not paused
return false;
}
}
/**
* Wait while automation is paused.
* Polls the browser pause state and blocks until unpaused.
* Called at the start of each step to allow user to pause between steps.
*/
private async waitIfPaused(): Promise<void> {
if (!this.page) {
return;
}
let wasPaused = false;
while (await this.isPausedInBrowser()) {
if (!wasPaused) {
this.log('info', 'Automation paused by user, waiting for resume...');
wasPaused = true;
}
await this.page.waitForTimeout(PlaywrightAutomationAdapter.PAUSE_CHECK_INTERVAL);
}
if (wasPaused) {
this.log('info', 'Automation resumed by user');
}
}
// ===== Browser Close Control =====
/**
* Check if the user has requested to close the browser.
* This is set by the close button in the overlay, ESC key press,
* or when the race creation modal is dismissed.
* @returns true if close was requested, false otherwise
*/
private async isCloseRequested(): Promise<boolean> {
if (!this.page) {
return false;
}
try {
return await this.page.evaluate(() =>
(window as unknown as { __gridpilot_close_requested?: boolean }).__gridpilot_close_requested === true
);
} catch {
// If we can't check (page navigating, etc.), assume not requested
return false;
}
}
/**
* Check if the race creation wizard modal has been closed by the user.
* Monitors for the modal being dismissed (user clicked away or closed it).
*
* IMPORTANT: During step transitions, React/Bootstrap may temporarily remove the 'in' class
* from the modal while updating content. To avoid false positives, this method:
* 1. Checks if ANY wizard step container is visible (means wizard is still active)
* 2. If not, waits 1000ms and checks again to confirm dismissal vs transition
*
* @returns true if the wizard modal is no longer visible (and was expected to be)
*/
private async isWizardModalDismissed(): Promise<boolean> {
if (!this.page || !this.isRealMode()) {
return false;
}
try {
// First check: Is ANY wizard step container attached to DOM?
// If yes, the wizard is still active (matches waitForWizardStep() criteria)
const stepContainerSelectors = Object.values(IRACING_SELECTORS.wizard.stepContainers);
for (const containerSelector of stepContainerSelectors) {
const count = await this.page.locator(containerSelector).count();
if (count > 0) {
this.log('debug', 'Wizard step container attached, wizard is active', { containerSelector });
return false;
}
}
// No step containers attached - wizard was likely dismissed
// Check if modal element exists at all (with or without 'in' class)
const modalSelector = '#create-race-modal, [role="dialog"], .modal.fade';
const modalExists = await this.page.locator(modalSelector).count() > 0;
if (!modalExists) {
this.log('debug', 'No wizard modal element found - dismissed');
return true;
}
// Modal exists but no step containers - could be transitioning
// Wait 1000ms and check again to confirm
this.log('debug', 'Wizard step containers not attached, waiting 1000ms to confirm dismissal vs transition');
await this.page.waitForTimeout(1000);
// Check step containers again after delay
for (const containerSelector of stepContainerSelectors) {
const count = await this.page.locator(containerSelector).count();
if (count > 0) {
this.log('debug', 'Wizard step container attached after delay - was just transitioning', { containerSelector });
return false;
}
}
// Still no step containers after delay - confirmed dismissed
this.log('info', 'No wizard step containers attached after delay - confirmed dismissed by user');
return true;
} catch {
return false;
}
}
/**
* Check for browser close conditions and close if triggered.
* Called during automation to check for user-initiated close actions.
* @throws Error with 'USER_CLOSE_REQUESTED' message if close was triggered
*/
async checkAndHandleClose(): Promise<void> {
if (!this.page) {
return;
}
// 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)');
await this.closeBrowserContext();
throw new Error('USER_CLOSE_REQUESTED: Browser closed by user request');
}
}
/**
* Check if the wizard modal was dismissed and close browser if so.
* This should be called during automation steps to detect if user
* navigated away from the wizard.
* @param currentStep Current step number to determine if modal should be visible
* @throws Error with 'WIZARD_DISMISSED' message if modal was closed by user
*/
async checkWizardDismissed(currentStep: number): Promise<void> {
if (!this.page || !this.isRealMode() || currentStep < 3) {
// Don't check before step 3 (modal opens at step 2)
return;
}
if (await this.isWizardModalDismissed()) {
this.log('info', 'Race creation wizard was dismissed by user');
await this.closeBrowserContext();
throw new Error('WIZARD_DISMISSED: User closed the race creation wizard');
}
}
/**
* Close the browser context completely.
* This is the nuclear option - closes everything to prevent
* the user from using the automation browser as a normal browser.
*/
async closeBrowserContext(): Promise<void> {
this.log('info', 'Closing browser context');
try {
// Remove overlay first (graceful exit)
await this.removeOverlay().catch(() => {});
// Close the persistent context if it exists
if (this.persistentContext) {
await this.persistentContext.close();
this.persistentContext = null;
this.page = null;
this.connected = false;
this.log('info', 'Persistent context closed');
return;
}
// Close non-persistent context
if (this.context) {
await this.context.close();
this.context = null;
this.page = null;
}
// Close browser
if (this.browser) {
await this.browser.close();
this.browser = null;
}
this.connected = false;
this.log('info', 'Browser closed successfully');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('warn', 'Error closing browser context', { error: message });
// Force cleanup
this.persistentContext = null;
this.context = null;
this.browser = null;
this.page = null;
this.connected = false;
}
}
/**
* Handle checkout confirmation flow in step 17.
* Extracts checkout info, shows overlay, requests confirmation via callback,
* and clicks checkout button only if confirmed.
*
* @throws Error if confirmation is cancelled or times out
*/
private async handleCheckoutConfirmation(): Promise<void> {
if (!this.page) {
throw new Error('Browser not connected');
}
this.log('info', 'Starting checkout confirmation flow');
try {
// Import CheckoutPriceExtractor dynamically to avoid circular dependencies
const { CheckoutPriceExtractor } = await import('./CheckoutPriceExtractor');
// Extract checkout info using existing extractor
const extractor = new CheckoutPriceExtractor(this.page);
const extractResult = await extractor.extractCheckoutInfo();
if (extractResult.isErr()) {
throw new Error(`Failed to extract checkout info: ${extractResult.error.message}`);
}
const checkoutInfo = extractResult.unwrap();
if (!checkoutInfo.price) {
throw new Error('No checkout price found');
}
// Show overlay: "Awaiting confirmation..."
await this.updateOverlay(17, '⏳ Awaiting confirmation...');
this.log('info', 'Requesting checkout confirmation', {
price: checkoutInfo.price.toDisplayString(),
ready: checkoutInfo.state.isReady()
});
// Call the confirmation callback
const confirmation = await this.checkoutConfirmationCallback!(
checkoutInfo.price,
checkoutInfo.state
);
this.log('info', 'Received confirmation decision', { decision: confirmation.value });
// Handle confirmation decision
if (confirmation.isCancelled()) {
throw new Error('Checkout cancelled by user');
}
if (confirmation.isTimeout()) {
throw new Error('Checkout confirmation timeout');
}
if (!confirmation.isConfirmed()) {
throw new Error(`Unexpected confirmation decision: ${confirmation.value}`);
}
// Confirmed - click the checkout button
this.log('info', 'Confirmation received, clicking checkout button');
// Try multiple selectors/fallbacks to locate the checkout button reliably across fixtures
const candidateSelectors = [
'.wizard-footer a.btn:has(span.label-pill)',
'.modal-footer a.btn:has(span.label-pill)',
'a.btn:has(span.label-pill)',
'.wizard-footer a:has(span.label-pill)',
'.modal-footer a:has(span.label-pill)',
'a:has(span.label-pill)'
];
let clicked = false;
for (const sel of candidateSelectors) {
try {
const count = await this.page!.locator(sel).first().count().catch(() => 0);
if (count > 0) {
this.log('debug', 'Found checkout button selector', { selector: sel });
await this.safeClick(sel, { timeout: this.config.timeout });
clicked = true;
break;
}
} catch (e) {
// continue to next candidate
}
}
// Last-resort: attempt to find the pill and click its ancestor <a>
if (!clicked) {
try {
const pill = this.page!.locator('span.label-pill').first();
if (await pill.count() > 0) {
const ancestor = pill.locator('xpath=ancestor::a[1]');
if (await ancestor.count() > 0) {
this.log('debug', 'Clicking checkout button via pill ancestor element');
// Use evaluate to click the element directly if safeClick by selector isn't possible
await ancestor.first().click({ timeout: this.config.timeout });
clicked = true;
}
}
} catch (e) {
// ignore and let the error be handled below
}
}
if (!clicked) {
throw new Error('Could not locate checkout button to click');
}
// Show success overlay
await this.updateOverlay(17, '✅ Checkout confirmed! Race creation in progress...');
this.log('info', 'Checkout button clicked successfully');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Checkout confirmation flow failed', { error: message });
throw error;
}
}
/**
* Setup browser close event listeners.
* Injects ESC key listener and modal visibility monitoring into the page.
* Should be called after overlay injection.
*/
async setupCloseListeners(): Promise<void> {
if (!this.page) {
return;
}
try {
this.log('info', 'Setting up browser close listeners');
// Inject ESC key listener and modal visibility observer
await this.page.evaluate(() => {
// Skip if already setup
if ((window as unknown as { __gridpilot_listeners_setup?: boolean }).__gridpilot_listeners_setup) {
return;
}
// ESC key listener - close browser on ESC press
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
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
// Check for removed nodes that might be the modal
for (const node of Array.from(mutation.removedNodes)) {
if (node instanceof HTMLElement) {
// 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
setTimeout(() => {
const wizardModal = document.querySelector('.modal.fade.in, .modal.show');
if (!wizardModal) {
console.log('[GridPilot] Wizard modal no longer visible, requesting close');
(window as unknown as { __gridpilot_close_requested?: boolean }).__gridpilot_close_requested = true;
}
}, 500);
}
}
}
// Check for class changes on modals (modal hiding)
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const target = mutation.target as HTMLElement;
if (target.classList.contains('modal') &&
!target.classList.contains('in') &&
!target.classList.contains('show')) {
// Modal is being hidden - check if it's the wizard
const isWizardModal = target.querySelector('.wizard-footer') !== null ||
target.id === 'create-hosted-race-modal';
if (isWizardModal) {
console.log('[GridPilot] Wizard modal hidden, requesting close');
(window as unknown as { __gridpilot_close_requested?: boolean }).__gridpilot_close_requested = true;
}
}
}
}
});
// Start observing
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class']
});
// Mark as setup
(window as unknown as { __gridpilot_listeners_setup?: boolean }).__gridpilot_listeners_setup = true;
console.log('[GridPilot] Close listeners setup complete');
});
this.log('info', 'Browser close listeners setup successfully');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('warn', 'Failed to setup close listeners', { error: message });
}
}
}