1005 lines
34 KiB
TypeScript
1005 lines
34 KiB
TypeScript
import puppeteer, { Browser, Page, CDPSession } from 'puppeteer-core';
|
|
import { StepId } from '../../../packages/domain/value-objects/StepId';
|
|
import { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation';
|
|
import {
|
|
NavigationResult,
|
|
FormFillResult,
|
|
ClickResult,
|
|
WaitResult,
|
|
ModalResult,
|
|
AutomationResult,
|
|
} from '../../../packages/application/ports/AutomationResults';
|
|
import { IRacingSelectorMap, getStepSelectors, getStepName, isModalStep } from './selectors/IRacingSelectorMap';
|
|
import type { ILogger, LogContext } from '../../../application/ports/ILogger';
|
|
import { NoOpLogAdapter } from '../logging/NoOpLogAdapter';
|
|
|
|
/**
|
|
* Configuration for connecting to browser via Chrome DevTools Protocol
|
|
*/
|
|
export interface DevToolsConfig {
|
|
/** WebSocket endpoint URL (e.g., ws://127.0.0.1:9222/devtools/browser/...) */
|
|
browserWSEndpoint?: string;
|
|
/** Chrome debugging port (default: 9222) */
|
|
debuggingPort?: number;
|
|
/** Default timeout for operations in milliseconds (default: 30000) */
|
|
defaultTimeout?: number;
|
|
/** Human-like typing delay in milliseconds (default: 50) */
|
|
typingDelay?: number;
|
|
/** Whether to wait for network idle after navigation (default: true) */
|
|
waitForNetworkIdle?: boolean;
|
|
}
|
|
|
|
/**
|
|
* BrowserDevToolsAdapter - Real browser automation using Puppeteer-core.
|
|
*
|
|
* This adapter connects to an existing browser session via Chrome DevTools Protocol (CDP)
|
|
* and automates the iRacing hosted session creation workflow.
|
|
*
|
|
* Key features:
|
|
* - Connects to existing browser (doesn't launch new one)
|
|
* - Uses IRacingSelectorMap for element location
|
|
* - Human-like typing delays for form filling
|
|
* - Waits for network idle after navigation
|
|
* - Disconnects without closing browser
|
|
*
|
|
* Usage:
|
|
* 1. Start Chrome with remote debugging:
|
|
* `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222`
|
|
* 2. Navigate to iRacing and log in manually
|
|
* 3. Create adapter and connect:
|
|
* ```
|
|
* const adapter = new BrowserDevToolsAdapter({ debuggingPort: 9222 });
|
|
* await adapter.connect();
|
|
* ```
|
|
*/
|
|
export class BrowserDevToolsAdapter implements IBrowserAutomation {
|
|
private browser: Browser | null = null;
|
|
private page: Page | null = null;
|
|
private config: Required<DevToolsConfig>;
|
|
private connected: boolean = false;
|
|
private logger: ILogger;
|
|
|
|
constructor(config: DevToolsConfig = {}, logger?: ILogger) {
|
|
this.config = {
|
|
browserWSEndpoint: config.browserWSEndpoint ?? '',
|
|
debuggingPort: config.debuggingPort ?? 9222,
|
|
defaultTimeout: config.defaultTimeout ?? 30000,
|
|
typingDelay: config.typingDelay ?? 50,
|
|
waitForNetworkIdle: config.waitForNetworkIdle ?? true,
|
|
};
|
|
this.logger = logger ?? new NoOpLogAdapter();
|
|
}
|
|
|
|
/**
|
|
* Connect to an existing browser via Chrome DevTools Protocol.
|
|
* The browser must be started with --remote-debugging-port flag.
|
|
*/
|
|
async connect(): Promise<void> {
|
|
if (this.connected) {
|
|
this.logger.debug('Already connected to browser');
|
|
return;
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
this.logger.info('Connecting to browser via CDP', {
|
|
debuggingPort: this.config.debuggingPort,
|
|
hasWsEndpoint: !!this.config.browserWSEndpoint
|
|
});
|
|
|
|
try {
|
|
if (this.config.browserWSEndpoint) {
|
|
// Connect using explicit WebSocket endpoint
|
|
this.logger.debug('Using explicit WebSocket endpoint');
|
|
this.browser = await puppeteer.connect({
|
|
browserWSEndpoint: this.config.browserWSEndpoint,
|
|
});
|
|
} else {
|
|
// Connect using debugging port - need to fetch endpoint first
|
|
this.logger.debug('Fetching WebSocket endpoint from debugging port');
|
|
const response = await fetch(`http://127.0.0.1:${this.config.debuggingPort}/json/version`);
|
|
const data = await response.json();
|
|
const wsEndpoint = data.webSocketDebuggerUrl;
|
|
|
|
this.browser = await puppeteer.connect({
|
|
browserWSEndpoint: wsEndpoint,
|
|
});
|
|
}
|
|
|
|
// Find iRacing tab or use the first available tab
|
|
const pages = await this.browser.pages();
|
|
this.page = await this.findIRacingPage(pages) || pages[0];
|
|
|
|
if (!this.page) {
|
|
throw new Error('No pages found in browser');
|
|
}
|
|
|
|
// Set default timeout
|
|
this.page.setDefaultTimeout(this.config.defaultTimeout);
|
|
|
|
this.connected = true;
|
|
const durationMs = Date.now() - startTime;
|
|
this.logger.info('Connected to browser successfully', {
|
|
durationMs,
|
|
pageUrl: this.page.url(),
|
|
totalPages: pages.length
|
|
});
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
this.logger.error('Failed to connect to browser', error instanceof Error ? error : new Error(errorMessage));
|
|
throw new Error(`Failed to connect to browser: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disconnect from the browser without closing it.
|
|
* The user can continue using the browser after disconnection.
|
|
*/
|
|
async disconnect(): Promise<void> {
|
|
this.logger.info('Disconnecting from browser');
|
|
if (this.browser) {
|
|
// Disconnect without closing - user may still use the browser
|
|
this.browser.disconnect();
|
|
this.browser = null;
|
|
this.page = null;
|
|
}
|
|
this.connected = false;
|
|
this.logger.debug('Browser disconnected');
|
|
}
|
|
|
|
/**
|
|
* Check if adapter is connected to browser.
|
|
*/
|
|
isConnected(): boolean {
|
|
return this.connected && this.browser !== null && this.page !== null;
|
|
}
|
|
|
|
/**
|
|
* Navigate to a URL and wait for the page to load.
|
|
*/
|
|
async navigateToPage(url: string): Promise<NavigationResult> {
|
|
this.ensureConnected();
|
|
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const waitUntil = this.config.waitForNetworkIdle ? 'networkidle2' : 'domcontentloaded';
|
|
await this.page!.goto(url, { waitUntil });
|
|
|
|
const loadTime = Date.now() - startTime;
|
|
|
|
return {
|
|
success: true,
|
|
url,
|
|
loadTime,
|
|
};
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
success: false,
|
|
url,
|
|
loadTime: Date.now() - startTime,
|
|
error: `Navigation failed: ${errorMessage}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fill a form field with human-like typing delay.
|
|
*
|
|
* @param fieldName - Field identifier (will be looked up in selector map or used directly)
|
|
* @param value - Value to type into the field
|
|
*/
|
|
async fillFormField(fieldName: string, value: string): Promise<FormFillResult> {
|
|
this.ensureConnected();
|
|
|
|
try {
|
|
// Try to find the element
|
|
const element = await this.page!.$(fieldName);
|
|
|
|
if (!element) {
|
|
return {
|
|
success: false,
|
|
fieldName,
|
|
valueSet: '',
|
|
error: `Field not found: ${fieldName}`,
|
|
};
|
|
}
|
|
|
|
// Clear existing value and type new value with human-like delay
|
|
await element.click({ clickCount: 3 }); // Select all existing text
|
|
await element.type(value, { delay: this.config.typingDelay });
|
|
|
|
return {
|
|
success: true,
|
|
fieldName,
|
|
valueSet: value,
|
|
};
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
success: false,
|
|
fieldName,
|
|
valueSet: '',
|
|
error: `Failed to fill field: ${errorMessage}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Click an element on the page.
|
|
*/
|
|
async clickElement(selector: string): Promise<ClickResult> {
|
|
this.ensureConnected();
|
|
|
|
try {
|
|
// Wait for element to be visible and clickable
|
|
await this.page!.waitForSelector(selector, {
|
|
visible: true,
|
|
timeout: this.config.defaultTimeout
|
|
});
|
|
|
|
await this.page!.click(selector);
|
|
|
|
return {
|
|
success: true,
|
|
target: selector,
|
|
};
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
success: false,
|
|
target: selector,
|
|
error: `Click failed: ${errorMessage}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for an element to appear on the page.
|
|
*/
|
|
async waitForElement(selector: string, maxWaitMs: number = 5000): Promise<WaitResult> {
|
|
this.ensureConnected();
|
|
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
await this.page!.waitForSelector(selector, {
|
|
timeout: maxWaitMs,
|
|
visible: true
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
target: selector,
|
|
waitedMs: Date.now() - startTime,
|
|
found: true,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
target: selector,
|
|
waitedMs: Date.now() - startTime,
|
|
found: false,
|
|
error: `Element not found within ${maxWaitMs}ms`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle modal operations for specific workflow steps.
|
|
* Modal steps are: 6 (SET_ADMINS), 9 (ADD_CAR), 12 (ADD_TRACK)
|
|
*/
|
|
async handleModal(stepId: StepId, action: string): Promise<ModalResult> {
|
|
this.ensureConnected();
|
|
|
|
if (!stepId.isModalStep()) {
|
|
return {
|
|
success: false,
|
|
stepId: stepId.value,
|
|
action,
|
|
error: `Step ${stepId.value} (${getStepName(stepId.value)}) is not a modal step`,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const stepSelectors = getStepSelectors(stepId.value);
|
|
|
|
if (!stepSelectors?.modal) {
|
|
return {
|
|
success: false,
|
|
stepId: stepId.value,
|
|
action,
|
|
error: `No modal selectors defined for step ${stepId.value}`,
|
|
};
|
|
}
|
|
|
|
const modalSelectors = stepSelectors.modal;
|
|
|
|
switch (action) {
|
|
case 'open':
|
|
// Wait for and verify modal is open
|
|
await this.page!.waitForSelector(modalSelectors.container, {
|
|
visible: true,
|
|
timeout: this.config.defaultTimeout,
|
|
});
|
|
break;
|
|
|
|
case 'close':
|
|
// Click close button
|
|
await this.page!.click(modalSelectors.closeButton);
|
|
// Wait for modal to disappear
|
|
await this.page!.waitForSelector(modalSelectors.container, {
|
|
hidden: true,
|
|
timeout: this.config.defaultTimeout,
|
|
});
|
|
break;
|
|
|
|
case 'search':
|
|
// Focus search input if available
|
|
if (modalSelectors.searchInput) {
|
|
await this.page!.click(modalSelectors.searchInput);
|
|
}
|
|
break;
|
|
|
|
case 'select':
|
|
// Click select/confirm button
|
|
if (modalSelectors.selectButton) {
|
|
await this.page!.click(modalSelectors.selectButton);
|
|
}
|
|
break;
|
|
|
|
default:
|
|
return {
|
|
success: false,
|
|
stepId: stepId.value,
|
|
action,
|
|
error: `Unknown modal action: ${action}`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
stepId: stepId.value,
|
|
action,
|
|
};
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
success: false,
|
|
stepId: stepId.value,
|
|
action,
|
|
error: `Modal operation failed: ${errorMessage}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ============== Helper Methods ==============
|
|
|
|
/**
|
|
* Find the iRacing page among open browser tabs.
|
|
*/
|
|
private async findIRacingPage(pages: Page[]): Promise<Page | null> {
|
|
for (const page of pages) {
|
|
const url = page.url();
|
|
if (url.includes('iracing.com') || url.includes('members-ng.iracing.com')) {
|
|
return page;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Ensure adapter is connected before operations.
|
|
*/
|
|
private ensureConnected(): void {
|
|
if (!this.isConnected()) {
|
|
throw new Error('Not connected to browser. Call connect() first.');
|
|
}
|
|
}
|
|
|
|
// ============== Extended Methods for Workflow Automation ==============
|
|
|
|
/**
|
|
* Navigate to a specific step in the wizard using sidebar navigation.
|
|
*/
|
|
async navigateToStep(stepId: StepId): Promise<NavigationResult> {
|
|
this.ensureConnected();
|
|
|
|
const startTime = Date.now();
|
|
const stepSelectors = getStepSelectors(stepId.value);
|
|
|
|
if (!stepSelectors?.sidebarLink) {
|
|
return {
|
|
success: false,
|
|
url: '',
|
|
loadTime: 0,
|
|
error: `No sidebar link defined for step ${stepId.value} (${getStepName(stepId.value)})`,
|
|
};
|
|
}
|
|
|
|
try {
|
|
await this.page!.click(stepSelectors.sidebarLink);
|
|
|
|
// Wait for step container to be visible
|
|
if (stepSelectors.container) {
|
|
await this.page!.waitForSelector(stepSelectors.container, { visible: true });
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
url: this.page!.url(),
|
|
loadTime: Date.now() - startTime,
|
|
};
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
success: false,
|
|
url: this.page!.url(),
|
|
loadTime: Date.now() - startTime,
|
|
error: `Failed to navigate to step: ${errorMessage}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current page URL.
|
|
*/
|
|
getCurrentUrl(): string {
|
|
if (!this.page) {
|
|
return '';
|
|
}
|
|
return this.page.url();
|
|
}
|
|
|
|
/**
|
|
* Take a screenshot of the current page (useful for debugging).
|
|
*/
|
|
async takeScreenshot(path: string): Promise<void> {
|
|
this.ensureConnected();
|
|
await this.page!.screenshot({ path, fullPage: true });
|
|
}
|
|
|
|
/**
|
|
* Get the current page content (useful for debugging).
|
|
*/
|
|
async getPageContent(): Promise<string> {
|
|
this.ensureConnected();
|
|
return await this.page!.content();
|
|
}
|
|
|
|
/**
|
|
* Wait for network to be idle (no pending requests).
|
|
*/
|
|
async waitForNetworkIdle(timeout: number = 5000): Promise<void> {
|
|
this.ensureConnected();
|
|
await this.page!.waitForNetworkIdle({ timeout });
|
|
}
|
|
|
|
/**
|
|
* Execute JavaScript in the page context.
|
|
*/
|
|
async evaluate<T>(fn: () => T): Promise<T> {
|
|
this.ensureConnected();
|
|
return await this.page!.evaluate(fn);
|
|
}
|
|
|
|
// ============== Step Execution ==============
|
|
|
|
/**
|
|
* Execute a complete workflow step with all required browser operations.
|
|
* Uses IRacingSelectorMap to locate elements and performs appropriate actions.
|
|
*
|
|
* Step workflow:
|
|
* 1. LOGIN - Skip (user pre-authenticated)
|
|
* 2. HOSTED_RACING - Navigate to hosted racing page
|
|
* 3. CREATE_RACE - Click create race button
|
|
* 4. RACE_INFORMATION - Fill session name, password, description
|
|
* 5. SERVER_DETAILS - Select server region, launch time
|
|
* 6. SET_ADMINS - Add admins (modal step)
|
|
* 7. TIME_LIMITS - Set practice/qualify/race times
|
|
* 8. SET_CARS - Configure car selection
|
|
* 9. ADD_CAR - Add cars (modal step)
|
|
* 10. SET_CAR_CLASSES - Configure car classes
|
|
* 11. SET_TRACK - Select track
|
|
* 12. ADD_TRACK - Add track (modal step)
|
|
* 13. TRACK_OPTIONS - Track configuration
|
|
* 14. TIME_OF_DAY - Set time of day
|
|
* 15. WEATHER - Weather settings
|
|
* 16. RACE_OPTIONS - Race rules and options
|
|
* 17. TEAM_DRIVING - Team settings
|
|
* 18. TRACK_CONDITIONS - Final review (SAFETY STOP - no checkout)
|
|
*
|
|
* @param stepId - The step to execute (1-18)
|
|
* @param config - Session configuration with form field values
|
|
* @returns AutomationResult with success/failure and metadata
|
|
*/
|
|
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
|
|
this.ensureConnected();
|
|
|
|
const stepNumber = stepId.value;
|
|
const stepSelectors = getStepSelectors(stepNumber);
|
|
const stepName = getStepName(stepNumber);
|
|
const startTime = Date.now();
|
|
|
|
this.logger.info('Executing step', { stepId: stepNumber, stepName });
|
|
|
|
try {
|
|
switch (stepNumber) {
|
|
case 1: // LOGIN - Skip, user already authenticated
|
|
return {
|
|
success: true,
|
|
metadata: {
|
|
skipped: true,
|
|
reason: 'User pre-authenticated',
|
|
step: stepName
|
|
}
|
|
};
|
|
|
|
case 2: // HOSTED_RACING - Navigate to hosted racing page
|
|
return await this.executeHostedRacingStep();
|
|
|
|
case 3: // CREATE_RACE - Click create race button
|
|
return await this.executeCreateRaceStep(stepSelectors);
|
|
|
|
case 4: // RACE_INFORMATION - Fill session details
|
|
return await this.executeRaceInformationStep(stepSelectors, config);
|
|
|
|
case 5: // SERVER_DETAILS - Configure server settings
|
|
return await this.executeServerDetailsStep(stepSelectors, config);
|
|
|
|
case 6: // SET_ADMINS - Add admins (modal step)
|
|
return await this.executeSetAdminsStep(stepSelectors, config);
|
|
|
|
case 7: // TIME_LIMITS - Configure time limits
|
|
return await this.executeTimeLimitsStep(stepSelectors, config);
|
|
|
|
case 8: // SET_CARS - Configure car selection
|
|
return await this.executeSetCarsStep(stepSelectors);
|
|
|
|
case 9: // ADD_CAR - Add cars (modal step)
|
|
return await this.executeAddCarStep(stepSelectors, config);
|
|
|
|
case 10: // SET_CAR_CLASSES - Configure car classes
|
|
return await this.executeSetCarClassesStep(stepSelectors, config);
|
|
|
|
case 11: // SET_TRACK - Select track
|
|
return await this.executeSetTrackStep(stepSelectors);
|
|
|
|
case 12: // ADD_TRACK - Add track (modal step)
|
|
return await this.executeAddTrackStep(stepSelectors, config);
|
|
|
|
case 13: // TRACK_OPTIONS - Track configuration
|
|
return await this.executeTrackOptionsStep(stepSelectors, config);
|
|
|
|
case 14: // TIME_OF_DAY - Set time of day
|
|
return await this.executeTimeOfDayStep(stepSelectors, config);
|
|
|
|
case 15: // WEATHER - Weather settings
|
|
return await this.executeWeatherStep(stepSelectors, config);
|
|
|
|
case 16: // RACE_OPTIONS - Race rules and options
|
|
return await this.executeRaceOptionsStep(stepSelectors, config);
|
|
|
|
case 17: // TEAM_DRIVING - Team settings
|
|
return await this.executeTeamDrivingStep(stepSelectors, config);
|
|
|
|
case 18: // TRACK_CONDITIONS - Final review (SAFETY STOP)
|
|
return await this.executeTrackConditionsStep(stepSelectors, config);
|
|
|
|
default:
|
|
this.logger.warn('Unknown step requested', { stepId: stepNumber });
|
|
return {
|
|
success: false,
|
|
error: `Unknown step: ${stepNumber}`,
|
|
metadata: { step: stepName }
|
|
};
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
const durationMs = Date.now() - startTime;
|
|
this.logger.error('Step execution failed', error instanceof Error ? error : new Error(errorMessage), {
|
|
stepId: stepNumber,
|
|
stepName,
|
|
durationMs
|
|
});
|
|
return {
|
|
success: false,
|
|
error: errorMessage,
|
|
metadata: { step: stepName }
|
|
};
|
|
}
|
|
}
|
|
|
|
// ============== Individual Step Implementations ==============
|
|
|
|
private async executeHostedRacingStep(): Promise<AutomationResult> {
|
|
const navResult = await this.navigateToPage(IRacingSelectorMap.urls.hostedRacing);
|
|
if (!navResult.success) {
|
|
return { success: false, error: navResult.error, metadata: { step: 'HOSTED_RACING' } };
|
|
}
|
|
|
|
// Wait for page to be ready
|
|
const stepSelectors = getStepSelectors(2);
|
|
if (stepSelectors?.container) {
|
|
await this.waitForElement(stepSelectors.container, this.config.defaultTimeout);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
metadata: { step: 'HOSTED_RACING', url: IRacingSelectorMap.urls.hostedRacing }
|
|
};
|
|
}
|
|
|
|
private async executeCreateRaceStep(stepSelectors: ReturnType<typeof getStepSelectors>): Promise<AutomationResult> {
|
|
if (!stepSelectors?.buttons?.createRace) {
|
|
return { success: false, error: 'Create race button selector not defined', metadata: { step: 'CREATE_RACE' } };
|
|
}
|
|
|
|
const clickResult = await this.clickElement(stepSelectors.buttons.createRace);
|
|
if (!clickResult.success) {
|
|
return { success: false, error: clickResult.error, metadata: { step: 'CREATE_RACE' } };
|
|
}
|
|
|
|
// Wait for wizard modal to appear
|
|
const waitResult = await this.waitForElement(IRacingSelectorMap.common.wizardContainer, this.config.defaultTimeout);
|
|
if (!waitResult.success) {
|
|
return { success: false, error: 'Wizard did not open', metadata: { step: 'CREATE_RACE' } };
|
|
}
|
|
|
|
return { success: true, metadata: { step: 'CREATE_RACE' } };
|
|
}
|
|
|
|
private async executeRaceInformationStep(
|
|
stepSelectors: ReturnType<typeof getStepSelectors>,
|
|
config: Record<string, unknown>
|
|
): Promise<AutomationResult> {
|
|
// Fill session name if provided
|
|
if (config.sessionName && stepSelectors?.fields?.sessionName) {
|
|
const fillResult = await this.fillFormField(stepSelectors.fields.sessionName, config.sessionName as string);
|
|
if (!fillResult.success) {
|
|
return { success: false, error: fillResult.error, metadata: { step: 'RACE_INFORMATION', field: 'sessionName' } };
|
|
}
|
|
}
|
|
|
|
// Fill password if provided
|
|
if (config.password && stepSelectors?.fields?.password) {
|
|
const fillResult = await this.fillFormField(stepSelectors.fields.password, config.password as string);
|
|
if (!fillResult.success) {
|
|
return { success: false, error: fillResult.error, metadata: { step: 'RACE_INFORMATION', field: 'password' } };
|
|
}
|
|
}
|
|
|
|
// Fill description if provided
|
|
if (config.description && stepSelectors?.fields?.description) {
|
|
const fillResult = await this.fillFormField(stepSelectors.fields.description, config.description as string);
|
|
if (!fillResult.success) {
|
|
return { success: false, error: fillResult.error, metadata: { step: 'RACE_INFORMATION', field: 'description' } };
|
|
}
|
|
}
|
|
|
|
// Click next button to proceed
|
|
if (stepSelectors?.buttons?.next) {
|
|
const clickResult = await this.clickElement(stepSelectors.buttons.next);
|
|
if (!clickResult.success) {
|
|
return { success: false, error: clickResult.error, metadata: { step: 'RACE_INFORMATION', action: 'next' } };
|
|
}
|
|
}
|
|
|
|
return { success: true, metadata: { step: 'RACE_INFORMATION' } };
|
|
}
|
|
|
|
private async executeServerDetailsStep(
|
|
stepSelectors: ReturnType<typeof getStepSelectors>,
|
|
config: Record<string, unknown>
|
|
): Promise<AutomationResult> {
|
|
// Server region selection would require dropdown interaction
|
|
// For now, accept defaults unless specific configuration is provided
|
|
|
|
// Click next button to proceed
|
|
if (stepSelectors?.buttons?.next) {
|
|
const clickResult = await this.clickElement(stepSelectors.buttons.next);
|
|
if (!clickResult.success) {
|
|
return { success: false, error: clickResult.error, metadata: { step: 'SERVER_DETAILS', action: 'next' } };
|
|
}
|
|
}
|
|
|
|
return { success: true, metadata: { step: 'SERVER_DETAILS' } };
|
|
}
|
|
|
|
private async executeSetAdminsStep(
|
|
stepSelectors: ReturnType<typeof getStepSelectors>,
|
|
config: Record<string, unknown>
|
|
): Promise<AutomationResult> {
|
|
// Admin step is a modal step - check if we need to add admins
|
|
const adminIds = config.adminIds as string[] | undefined;
|
|
|
|
if (adminIds && adminIds.length > 0 && stepSelectors?.modal) {
|
|
// Open admin modal
|
|
if (stepSelectors.buttons?.addAdmin) {
|
|
const clickResult = await this.clickElement(stepSelectors.buttons.addAdmin);
|
|
if (!clickResult.success) {
|
|
return { success: false, error: clickResult.error, metadata: { step: 'SET_ADMINS', action: 'openModal' } };
|
|
}
|
|
|
|
// Wait for modal to appear
|
|
await this.waitForElement(stepSelectors.modal.container, this.config.defaultTimeout);
|
|
|
|
// Search and select admins would require more complex interaction
|
|
// For now, close the modal
|
|
if (stepSelectors.modal.closeButton) {
|
|
await this.clickElement(stepSelectors.modal.closeButton);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Click next button to proceed
|
|
if (stepSelectors?.buttons?.next) {
|
|
const clickResult = await this.clickElement(stepSelectors.buttons.next);
|
|
if (!clickResult.success) {
|
|
return { success: false, error: clickResult.error, metadata: { step: 'SET_ADMINS', action: 'next' } };
|
|
}
|
|
}
|
|
|
|
return { success: true, metadata: { step: 'SET_ADMINS', isModalStep: true } };
|
|
}
|
|
|
|
private async executeTimeLimitsStep(
|
|
stepSelectors: ReturnType<typeof getStepSelectors>,
|
|
config: Record<string, unknown>
|
|
): Promise<AutomationResult> {
|
|
// Fill practice length if provided
|
|
if (config.practiceLength && stepSelectors?.fields?.practiceLength) {
|
|
await this.fillFormField(stepSelectors.fields.practiceLength, String(config.practiceLength));
|
|
}
|
|
|
|
// Fill qualify length if provided
|
|
if (config.qualifyingLength && stepSelectors?.fields?.qualifyLength) {
|
|
await this.fillFormField(stepSelectors.fields.qualifyLength, String(config.qualifyingLength));
|
|
}
|
|
|
|
// Fill race length if provided
|
|
if (config.raceLength && stepSelectors?.fields?.raceLength) {
|
|
await this.fillFormField(stepSelectors.fields.raceLength, String(config.raceLength));
|
|
}
|
|
|
|
// Fill warmup length if provided
|
|
if (config.warmupLength && stepSelectors?.fields?.warmupLength) {
|
|
await this.fillFormField(stepSelectors.fields.warmupLength, String(config.warmupLength));
|
|
}
|
|
|
|
// Click next button to proceed
|
|
if (stepSelectors?.buttons?.next) {
|
|
const clickResult = await this.clickElement(stepSelectors.buttons.next);
|
|
if (!clickResult.success) {
|
|
return { success: false, error: clickResult.error, metadata: { step: 'TIME_LIMITS', action: 'next' } };
|
|
}
|
|
}
|
|
|
|
return { success: true, metadata: { step: 'TIME_LIMITS' } };
|
|
}
|
|
|
|
private async executeSetCarsStep(stepSelectors: ReturnType<typeof getStepSelectors>): Promise<AutomationResult> {
|
|
// This step shows the car selection overview
|
|
// Actual car addition happens in step 9 (ADD_CAR modal)
|
|
|
|
// Click next button to proceed to track selection
|
|
if (stepSelectors?.buttons?.next) {
|
|
const clickResult = await this.clickElement(stepSelectors.buttons.next);
|
|
if (!clickResult.success) {
|
|
return { success: false, error: clickResult.error, metadata: { step: 'SET_CARS', action: 'next' } };
|
|
}
|
|
}
|
|
|
|
return { success: true, metadata: { step: 'SET_CARS' } };
|
|
}
|
|
|
|
private async executeAddCarStep(
|
|
stepSelectors: ReturnType<typeof getStepSelectors>,
|
|
config: Record<string, unknown>
|
|
): Promise<AutomationResult> {
|
|
// Add car is a modal step
|
|
const carIds = config.carIds as string[] | undefined;
|
|
|
|
if (carIds && carIds.length > 0 && stepSelectors?.modal) {
|
|
// Click add car button to open modal
|
|
const step8Selectors = getStepSelectors(8);
|
|
if (step8Selectors?.buttons?.addCar) {
|
|
const clickResult = await this.clickElement(step8Selectors.buttons.addCar);
|
|
if (!clickResult.success) {
|
|
return { success: false, error: clickResult.error, metadata: { step: 'ADD_CAR', action: 'openModal' } };
|
|
}
|
|
|
|
// Wait for modal to appear
|
|
await this.waitForElement(stepSelectors.modal.container, this.config.defaultTimeout);
|
|
|
|
// Search for car would require typing in search field
|
|
// For each car, we would search and select
|
|
// For now, this is a placeholder for more complex car selection logic
|
|
|
|
// Close modal after selection (or if no action needed)
|
|
if (stepSelectors.modal.closeButton) {
|
|
await this.clickElement(stepSelectors.modal.closeButton);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: true, metadata: { step: 'ADD_CAR', isModalStep: true, carCount: carIds?.length ?? 0 } };
|
|
}
|
|
|
|
private async executeSetCarClassesStep(
|
|
stepSelectors: ReturnType<typeof getStepSelectors>,
|
|
config: Record<string, unknown>
|
|
): Promise<AutomationResult> {
|
|
// Car classes configuration - usually auto-configured based on selected cars
|
|
// Click next button to proceed
|
|
if (stepSelectors?.buttons?.next) {
|
|
const clickResult = await this.clickElement(stepSelectors.buttons.next);
|
|
if (!clickResult.success) {
|
|
return { success: false, error: clickResult.error, metadata: { step: 'SET_CAR_CLASSES', action: 'next' } };
|
|
}
|
|
}
|
|
|
|
return { success: true, metadata: { step: 'SET_CAR_CLASSES' } };
|
|
}
|
|
|
|
private async executeSetTrackStep(stepSelectors: ReturnType<typeof getStepSelectors>): Promise<AutomationResult> {
|
|
// This step shows the track selection overview
|
|
// Actual track selection happens in step 12 (ADD_TRACK modal)
|
|
|
|
// Click next button to proceed
|
|
if (stepSelectors?.buttons?.next) {
|
|
const clickResult = await this.clickElement(stepSelectors.buttons.next);
|
|
if (!clickResult.success) {
|
|
return { success: false, error: clickResult.error, metadata: { step: 'SET_TRACK', action: 'next' } };
|
|
}
|
|
}
|
|
|
|
return { success: true, metadata: { step: 'SET_TRACK' } };
|
|
}
|
|
|
|
private async executeAddTrackStep(
|
|
stepSelectors: ReturnType<typeof getStepSelectors>,
|
|
config: Record<string, unknown>
|
|
): Promise<AutomationResult> {
|
|
// Add track is a modal step
|
|
const trackId = config.trackId as string | undefined;
|
|
|
|
if (trackId && stepSelectors?.modal) {
|
|
// Click add track button to open modal
|
|
const step11Selectors = getStepSelectors(11);
|
|
if (step11Selectors?.buttons?.addTrack) {
|
|
const clickResult = await this.clickElement(step11Selectors.buttons.addTrack);
|
|
if (!clickResult.success) {
|
|
return { success: false, error: clickResult.error, metadata: { step: 'ADD_TRACK', action: 'openModal' } };
|
|
}
|
|
|
|
// Wait for modal to appear
|
|
await this.waitForElement(stepSelectors.modal.container, this.config.defaultTimeout);
|
|
|
|
// Search for track would require typing in search field
|
|
// For now, this is a placeholder for more complex track selection logic
|
|
|
|
// Close modal after selection (or if no action needed)
|
|
if (stepSelectors.modal.closeButton) {
|
|
await this.clickElement(stepSelectors.modal.closeButton);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: true, metadata: { step: 'ADD_TRACK', isModalStep: true, trackId } };
|
|
}
|
|
|
|
private async executeTrackOptionsStep(
|
|
stepSelectors: ReturnType<typeof getStepSelectors>,
|
|
config: Record<string, unknown>
|
|
): Promise<AutomationResult> {
|
|
// Track options like configuration, pit stalls etc.
|
|
// Accept defaults for now
|
|
|
|
// Click next button to proceed
|
|
if (stepSelectors?.buttons?.next) {
|
|
const clickResult = await this.clickElement(stepSelectors.buttons.next);
|
|
if (!clickResult.success) {
|
|
return { success: false, error: clickResult.error, metadata: { step: 'TRACK_OPTIONS', action: 'next' } };
|
|
}
|
|
}
|
|
|
|
return { success: true, metadata: { step: 'TRACK_OPTIONS' } };
|
|
}
|
|
|
|
private async executeTimeOfDayStep(
|
|
stepSelectors: ReturnType<typeof getStepSelectors>,
|
|
config: Record<string, unknown>
|
|
): Promise<AutomationResult> {
|
|
// Time of day configuration
|
|
// Accept defaults for now - time sliders are complex to interact with
|
|
|
|
// Click next button to proceed
|
|
if (stepSelectors?.buttons?.next) {
|
|
const clickResult = await this.clickElement(stepSelectors.buttons.next);
|
|
if (!clickResult.success) {
|
|
return { success: false, error: clickResult.error, metadata: { step: 'TIME_OF_DAY', action: 'next' } };
|
|
}
|
|
}
|
|
|
|
return { success: true, metadata: { step: 'TIME_OF_DAY' } };
|
|
}
|
|
|
|
private async executeWeatherStep(
|
|
stepSelectors: ReturnType<typeof getStepSelectors>,
|
|
config: Record<string, unknown>
|
|
): Promise<AutomationResult> {
|
|
// Weather configuration
|
|
// Accept defaults for now
|
|
|
|
// Click next button to proceed
|
|
if (stepSelectors?.buttons?.next) {
|
|
const clickResult = await this.clickElement(stepSelectors.buttons.next);
|
|
if (!clickResult.success) {
|
|
return { success: false, error: clickResult.error, metadata: { step: 'WEATHER', action: 'next' } };
|
|
}
|
|
}
|
|
|
|
return { success: true, metadata: { step: 'WEATHER' } };
|
|
}
|
|
|
|
private async executeRaceOptionsStep(
|
|
stepSelectors: ReturnType<typeof getStepSelectors>,
|
|
config: Record<string, unknown>
|
|
): Promise<AutomationResult> {
|
|
// Race options like max drivers, hardcore incidents, etc.
|
|
// Fill max drivers if provided
|
|
if (config.maxDrivers && stepSelectors?.fields?.maxDrivers) {
|
|
await this.fillFormField(stepSelectors.fields.maxDrivers, String(config.maxDrivers));
|
|
}
|
|
|
|
// Click next button to proceed
|
|
if (stepSelectors?.buttons?.next) {
|
|
const clickResult = await this.clickElement(stepSelectors.buttons.next);
|
|
if (!clickResult.success) {
|
|
return { success: false, error: clickResult.error, metadata: { step: 'RACE_OPTIONS', action: 'next' } };
|
|
}
|
|
}
|
|
|
|
return { success: true, metadata: { step: 'RACE_OPTIONS' } };
|
|
}
|
|
|
|
private async executeTeamDrivingStep(
|
|
stepSelectors: ReturnType<typeof getStepSelectors>,
|
|
config: Record<string, unknown>
|
|
): Promise<AutomationResult> {
|
|
// Team driving configuration
|
|
// Accept defaults for now (usually disabled)
|
|
|
|
// Click next button to proceed
|
|
if (stepSelectors?.buttons?.next) {
|
|
const clickResult = await this.clickElement(stepSelectors.buttons.next);
|
|
if (!clickResult.success) {
|
|
return { success: false, error: clickResult.error, metadata: { step: 'TEAM_DRIVING', action: 'next' } };
|
|
}
|
|
}
|
|
|
|
return { success: true, metadata: { step: 'TEAM_DRIVING' } };
|
|
}
|
|
|
|
private async executeTrackConditionsStep(
|
|
stepSelectors: ReturnType<typeof getStepSelectors>,
|
|
config: Record<string, unknown>
|
|
): Promise<AutomationResult> {
|
|
// FINAL STEP - SAFETY STOP
|
|
// We fill track conditions but DO NOT click checkout button
|
|
|
|
// Track state selection would require dropdown interaction
|
|
// For now, accept defaults
|
|
|
|
return {
|
|
success: true,
|
|
metadata: {
|
|
step: 'TRACK_CONDITIONS',
|
|
safetyStop: true,
|
|
message: 'Automation stopped at final step. User must review configuration and click checkout manually.',
|
|
checkoutButtonSelector: IRacingSelectorMap.common.checkoutButton
|
|
}
|
|
};
|
|
}
|
|
} |