Files
gridpilot.gg/packages/infrastructure/adapters/automation/PlaywrightAutomationAdapter.ts

3127 lines
117 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 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';
/**
* Selector constants for data-* attribute based automation.
* These selectors target stable attributes on mock fixtures.
*/
const MOCK_SELECTORS = {
stepContainer: (step: number) => `[data-step="${step}"]`,
stepIndicator: (name: string) => `[data-indicator="${name}"]`,
nextButton: '[data-action="next"]',
backButton: '[data-action="back"]',
confirmButton: '[data-action="confirm"]',
cancelButton: '[data-action="cancel"]',
createButton: '[data-action="create"]',
addButton: '[data-action="add"]',
selectButton: '[data-action="select"]',
field: (name: string) => `[data-field="${name}"]`,
dropdown: (name: string) => `[data-dropdown="${name}"]`,
toggle: (name: string) => `[data-toggle="${name}"]`,
slider: (name: string) => `[data-slider="${name}"]`,
modal: '[data-modal="true"]',
modalTrigger: (type: string) => `[data-modal-trigger="${type}"]`,
list: (name: string) => `[data-list="${name}"]`,
listItem: (id: string) => `[data-item="${id}"]`,
} as const;
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: "🏁 Finalizing race options...",
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;
/** Polling interval for pause check (ms) */
private static readonly PAUSE_CHECK_INTERVAL = 300;
constructor(config: PlaywrightConfig = {}, logger?: ILogger) {
this.config = {
headless: true,
timeout: 10000,
baseUrl: '',
mode: 'mock',
userDataDir: '',
...config,
};
this.logger = logger;
this.cookieStore = new SessionCookieStore(this.config.userDataDir, logger);
}
private isRealMode(): boolean {
return this.config.mode === 'real';
}
private getSelector(mockSelector: string, realSelector: string): string {
return this.isRealMode() ? realSelector : mockSelector;
}
/** 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(): 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();
}
this.isConnecting = true;
try {
// 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,
headless: this.config.headless
});
// 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 chromium.launchPersistentContext(
this.config.userDataDir,
{
headless: this.config.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 chromium.launch({
headless: this.config.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;
}
}
/**
* 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 });
// 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 {
if (!this.isRealMode()) {
return MOCK_SELECTORS.field(fieldName);
}
// Map field names to iRacing selectors with fallbacks
const fieldMap: Record<string, string> = {
// Step 3: Race Information
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}`,
// Step 5/6: Admins
adminSearch: IRACING_SELECTORS.steps.adminSearch,
// Step 8/9: Cars
carSearch: IRACING_SELECTORS.steps.carSearch,
// Step 10/11/12: Track
trackSearch: IRACING_SELECTORS.steps.trackSearch,
// Step 16: Race Options
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;
}
if (!this.isRealMode()) {
return `[data-action="${action}"]`;
}
// Map actions to iRacing selectors
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 if (this.isRealMode()) {
// In real mode, wait for modal/wizard elements instead of step containers
selector = IRACING_SELECTORS.wizard.modal;
} else {
selector = MOCK_SELECTORS.stepContainer(parseInt(target, 10));
}
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 = this.getSelector(MOCK_SELECTORS.modal, 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 = this.getSelector(MOCK_SELECTORS.confirmButton, IRACING_SELECTORS.wizard.confirmButton);
} else if (action === 'cancel') {
buttonSelector = this.getSelector(MOCK_SELECTORS.cancelButton, 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();
}
// Inject and update overlay at the start of each step (real mode only)
if (this.isRealMode()) {
await this.updateOverlay(step);
// Check if wizard was dismissed by user (after step 2)
if (step > 2) {
await this.checkWizardDismissed(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:
// Mock mode: step 1 is a no-op (already on hosted page)
break;
case 2:
await this.clickAction('create');
// In real mode, a modal appears asking "Last Settings" or "New Race"
// We need to click "New Race" to proceed to the session creation form
if (this.isRealMode()) {
await this.clickNewRaceInModal();
}
break;
case 3:
// Step 3: Race Information - fill session details
// In real mode, wait for the wizard step to be visible
if (this.isRealMode()) {
await this.waitForWizardStep('raceInformation');
}
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));
}
await this.clickNextButton('Server Details');
break;
case 4:
// Step 4: Server Details
if (this.isRealMode()) {
await this.waitForWizardStep('serverDetails');
}
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');
}
await this.clickNextButton('Time Limit');
break;
case 6:
// Step 6: Add an Admin (modal) - OPTIONAL step
// This step only applies if the "Add Admin" modal is open.
// In normal flow, we skip from Step 5 (Admins) directly to Step 7 (Time Limits).
// Only execute if adminSearch is provided AND we can detect the modal is open.
if (config.adminSearch && this.isRealMode()) {
// Check if an admin search modal is actually visible
const adminModalVisible = await this.isAdminModalVisible();
if (adminModalVisible) {
await this.fillField('adminSearch', String(config.adminSearch));
// Click the confirm/select button in the admin modal (NOT the checkout button)
await this.clickAdminModalConfirm();
} else {
this.log('debug', 'Step 6: No admin modal visible, skipping');
}
} else if (!this.isRealMode()) {
// Mock mode behavior
if (config.adminSearch) {
await this.fillField('adminSearch', String(config.adminSearch));
}
await this.clickAction('confirm');
}
// If no adminSearch config and real mode, this step is a no-op
break;
case 7:
// Step 7: Time Limits
if (this.isRealMode()) {
await this.waitForWizardStep('timeLimit');
}
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 - add car BEFORE navigating to track
if (this.isRealMode()) {
await this.waitForWizardStep('cars');
}
// Add car BEFORE navigating away (handles what was Step 9)
if (this.isRealMode()) {
const carIds = config.carIds as string[] | undefined;
const carSearchTerm = config.carSearch || config.car || carIds?.[0];
if (carSearchTerm) {
// First, click the "Add Car" button to open the modal
await this.clickAddCarButton();
// Wait for the modal to appear
await this.waitForAddCarModal();
// Search for the car
await this.fillField('carSearch', String(carSearchTerm));
// Wait for search results to load
await this.page!.waitForTimeout(500);
// Select the first result by clicking its "Select" button
// This immediately adds the car (no confirm step needed - modal closes automatically)
await this.selectFirstSearchResult();
this.log('info', 'Added car to session', { car: carSearchTerm });
} else {
this.log('debug', 'Step 8: No car search term provided, skipping car addition');
}
} else {
// Mock mode behavior - add car if config provided
if (config.carSearch) {
await this.fillField('carSearch', String(config.carSearch));
await this.clickAction('confirm');
}
}
await this.clickNextButton('Track');
break;
case 9:
// Step 9: Add a Car - NOW A NO-OP (logic merged into Step 8)
// Car addition is handled in Step 8 before clicking "Next → Track"
this.log('info', 'Step 9: Skipping - car addition handled by Step 8');
break;
case 10:
// Step 10: Set Car Classes
if (this.isRealMode()) {
// Car classes might be auto-skipped or part of Set Cars
}
await this.clickNextButton('Track');
break;
case 11:
// Step 11: Set Track - add track BEFORE navigating to track options
if (this.isRealMode()) {
await this.waitForWizardStep('track');
}
// Add track BEFORE navigating away (handles what was Step 12)
if (this.isRealMode()) {
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 11: 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');
}
}
await this.clickNextButton('Track Options');
// Verify navigation succeeded
if (this.isRealMode()) {
await this.waitForWizardStep('trackOptions');
}
break;
case 12:
// Step 12: Add a Track - NOW A NO-OP (logic merged into Step 11)
// Track addition is handled in Step 11 before clicking "Next → Track Options"
this.log('info', 'Step 12: Skipping - track addition handled by Step 11');
break;
case 13:
// Step 13: Track Options
if (this.isRealMode()) {
await this.waitForWizardStep('trackOptions');
}
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 14:
// Step 14: Time of Day
if (this.isRealMode()) {
await this.waitForWizardStep('timeOfDay');
}
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 15:
// Step 15: Weather
// Note: Modern iRacing UI uses Chakra radio buttons for weather type, not a dropdown.
// The weather step may also be optional/skippable depending on track configuration.
if (this.isRealMode()) {
await this.waitForWizardStep('weather');
}
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('Race Options');
break;
case 16:
// Step 16: Race Options
// Note: Modern iRacing Race Options UI uses sliders (License Range, iRating Range, Incident Limit)
// and toggle switches - there is NO maxDrivers input field. The config options here are optional.
if (this.isRealMode()) {
await this.waitForWizardStep('raceOptions');
}
// maxDrivers field doesn't exist in modern iRacing - skip if not found
if (config.maxDrivers !== undefined) {
const maxDriversSelector = this.getFieldSelector('maxDrivers');
const maxDriversExists = await this.page!.locator(maxDriversSelector).first().count() > 0;
if (maxDriversExists) {
await this.fillField('maxDrivers', String(config.maxDrivers));
} else {
this.log('debug', 'maxDrivers field not found in Race Options, skipping');
}
}
// rollingStart toggle - also check if it exists
if (config.rollingStart !== undefined) {
const rollingStartSelector = this.getToggleSelector('rollingStart');
const rollingStartExists = await this.page!.locator(rollingStartSelector).first().count() > 0;
if (rollingStartExists) {
await this.setToggle('rollingStart', Boolean(config.rollingStart));
} else {
this.log('debug', 'rollingStart toggle not found in Race Options, skipping');
}
}
await this.clickNextButton('Track Conditions');
break;
case 17:
// Step 17: Track Conditions (final step)
// NOTE: We're already on Track Conditions page after step 16 clicked "Next → Track Conditions"
// No need to wait for the step container - just update overlay and let user review
// The overlay message for step 17 tells user to review and click "Host Race"
if (config.trackState && 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 if (config.trackState && !this.isRealMode()) {
await this.selectDropdown('trackState', String(config.trackState));
}
// Final step - don't click next, user must review and confirm
break;
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')}`;
}
return { success: false, error: errorMessage };
}
}
/**
* 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;
// 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 = IRACING_SELECTORS.steps.addCarButton;
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: IRACING_TIMEOUTS.elementWait,
});
await this.safeClick(addCarButtonSelector, { timeout: IRACING_TIMEOUTS.elementWait });
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: IRACING_TIMEOUTS.elementWait,
});
// 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: IRACING_TIMEOUTS.elementWait,
});
await this.safeClick(addTrackButtonSelector, { timeout: IRACING_TIMEOUTS.elementWait });
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: IRACING_TIMEOUTS.elementWait,
});
// 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.
* Otherwise navigates to login page and waits for user to complete manual login.
*/
private async handleLogin(): Promise<AutomationResult> {
if (!this.page) {
return { success: false, error: 'Browser not connected' };
}
try {
// Check if already authenticated by reading cookie store
const sessionResult = await this.checkSession();
if (sessionResult.isOk() && sessionResult.unwrap() === AuthenticationState.AUTHENTICATED) {
this.log('info', 'Already authenticated, navigating directly to hosted sessions');
await this.page.goto(IRACING_URLS.hostedSessions, {
waitUntil: 'domcontentloaded',
timeout: IRACING_TIMEOUTS.navigation,
});
this.log('info', 'Navigated to hosted sessions page');
return { success: true };
}
// 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 (indicates successful login)
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');
}
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(MOCK_SELECTORS.stepContainer(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 {
if (!this.isRealMode()) {
return MOCK_SELECTORS.dropdown(name);
}
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 {
if (!this.isRealMode()) {
return MOCK_SELECTORS.toggle(name);
}
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 {
if (!this.isRealMode()) {
return MOCK_SELECTORS.slider(name);
}
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 = this.getSelector(MOCK_SELECTORS.modal, 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 = this.isRealMode() ? `[data-item="${itemId}"], button:has-text("${itemId}")` : MOCK_SELECTORS.listItem(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 = this.isRealMode() ? `button:has-text("${type}"), [aria-label*="${type}" i]` : MOCK_SELECTORS.modalTrigger(type);
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;
}
/**
* Get the user data directory path for persistent sessions.
*/
getUserDataDir(): string {
return this.config.userDataDir;
}
// ===== 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 || !this.overlayInjected) {
return;
}
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).
* @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 {
// Check if the main wizard modal is no longer visible
// The modal should be visible during automation - if it's gone, user dismissed it
const modalSelector = IRACING_SELECTORS.wizard.modal;
const modalVisible = await this.page.locator(modalSelector).isVisible().catch(() => false);
// Only consider it "dismissed" if we're past step 2 (modal should be open)
// and the modal is not visible
return !modalVisible;
} 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;
}
}
/**
* 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 });
}
}
}