diff --git a/src/apps/companion/main/di-container.ts b/src/apps/companion/main/di-container.ts index f09057d1f..e116e565e 100644 --- a/src/apps/companion/main/di-container.ts +++ b/src/apps/companion/main/di-container.ts @@ -8,6 +8,11 @@ import type { ISessionRepository } from '../../../packages/application/ports/ISe import type { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation'; import type { IAutomationEngine } from '../../../packages/application/ports/IAutomationEngine'; +export interface BrowserConnectionResult { + success: boolean; + error?: string; +} + /** * Create browser automation adapter based on configuration mode. * @@ -90,6 +95,27 @@ export class DIContainer { return this.browserAutomation; } + /** + * Initialize browser connection for dev mode. + * In dev mode, connects to the browser via Chrome DevTools Protocol. + * In mock mode, returns success immediately (no connection needed). + */ + public async initializeBrowserConnection(): Promise { + if (this.automationMode === 'dev') { + try { + const devToolsAdapter = this.browserAutomation as BrowserDevToolsAdapter; + await devToolsAdapter.connect(); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to connect to browser' + }; + } + } + return { success: true }; // Mock mode doesn't need connection + } + /** * Reset the singleton instance (useful for testing with different configurations). */ diff --git a/src/apps/companion/main/ipc-handlers.ts b/src/apps/companion/main/ipc-handlers.ts index 1a214db0b..185fd05cf 100644 --- a/src/apps/companion/main/ipc-handlers.ts +++ b/src/apps/companion/main/ipc-handlers.ts @@ -3,6 +3,7 @@ import type { BrowserWindow, IpcMainInvokeEvent } from 'electron'; import { DIContainer } from './di-container'; import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig'; import { StepId } from '../../../packages/domain/value-objects/StepId'; +import { MockAutomationEngineAdapter } from '../../../infrastructure/adapters/automation/MockAutomationEngineAdapter'; export function setupIpcHandlers(mainWindow: BrowserWindow): void { const container = DIContainer.getInstance(); @@ -12,6 +13,12 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void { ipcMain.handle('start-automation', async (_event: IpcMainInvokeEvent, config: HostedSessionConfig) => { try { + // Connect to browser first (required for dev mode) + const connectionResult = await container.initializeBrowserConnection(); + if (!connectionResult.success) { + return { success: false, error: connectionResult.error }; + } + const result = await startAutomationUseCase.execute(config); const session = await sessionRepository.findById(result.sessionId); @@ -79,4 +86,26 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void { ipcMain.handle('resume-automation', async (_event: IpcMainInvokeEvent, _sessionId: string) => { return { success: false, error: 'Resume not implemented in POC' }; }); + + ipcMain.handle('stop-automation', async (_event: IpcMainInvokeEvent, sessionId: string) => { + try { + // Stop the automation engine interval + const engine = automationEngine as MockAutomationEngineAdapter; + engine.stopAutomation(); + + // Update session state to failed with user stop reason + const session = await sessionRepository.findById(sessionId); + if (session) { + session.fail('User stopped automation'); + await sessionRepository.update(session); + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + }); } \ No newline at end of file diff --git a/src/apps/companion/main/preload.ts b/src/apps/companion/main/preload.ts index 7ac11cd3a..949406ea5 100644 --- a/src/apps/companion/main/preload.ts +++ b/src/apps/companion/main/preload.ts @@ -3,6 +3,7 @@ import type { HostedSessionConfig } from '../../../packages/domain/entities/Host export interface ElectronAPI { startAutomation: (config: HostedSessionConfig) => Promise<{ success: boolean; sessionId?: string; error?: string }>; + stopAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>; getSessionStatus: (sessionId: string) => Promise; pauseAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>; resumeAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>; @@ -11,6 +12,7 @@ export interface ElectronAPI { contextBridge.exposeInMainWorld('electronAPI', { startAutomation: (config: HostedSessionConfig) => ipcRenderer.invoke('start-automation', config), + stopAutomation: (sessionId: string) => ipcRenderer.invoke('stop-automation', sessionId), getSessionStatus: (sessionId: string) => ipcRenderer.invoke('get-session-status', sessionId), pauseAutomation: (sessionId: string) => ipcRenderer.invoke('pause-automation', sessionId), resumeAutomation: (sessionId: string) => ipcRenderer.invoke('resume-automation', sessionId), diff --git a/src/apps/companion/renderer/App.tsx b/src/apps/companion/renderer/App.tsx index 802b15ef3..90b66e4d9 100644 --- a/src/apps/companion/renderer/App.tsx +++ b/src/apps/companion/renderer/App.tsx @@ -42,6 +42,18 @@ export function App() { } }; + const handleStopAutomation = async () => { + if (sessionId) { + const result = await window.electronAPI.stopAutomation(sessionId); + if (result.success) { + setIsRunning(false); + setProgress(prev => prev ? { ...prev, state: 'STOPPED', hasError: false, errorMessage: 'User stopped automation' } : null); + } else { + alert(`Failed to stop automation: ${result.error}`); + } + } + }; + return (
Hosted Session Automation POC

- + {isRunning && ( + + )}
): Promise { + this.ensureConnected(); + + const stepNumber = stepId.value; + const stepSelectors = getStepSelectors(stepNumber); + const stepName = getStepName(stepNumber); + + 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: + return { + success: false, + error: `Unknown step: ${stepNumber}`, + metadata: { step: stepName } + }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + success: false, + error: errorMessage, + metadata: { step: stepName } + }; + } + } + + // ============== Individual Step Implementations ============== + + private async executeHostedRacingStep(): Promise { + 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): Promise { + 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, + config: Record + ): Promise { + // 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, + config: Record + ): Promise { + // 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, + config: Record + ): Promise { + // 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, + config: Record + ): Promise { + // 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): Promise { + // 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, + config: Record + ): Promise { + // 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, + config: Record + ): Promise { + // 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): Promise { + // 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, + config: Record + ): Promise { + // 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, + config: Record + ): Promise { + // 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, + config: Record + ): Promise { + // 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, + config: Record + ): Promise { + // 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, + config: Record + ): Promise { + // 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, + config: Record + ): Promise { + // 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, + config: Record + ): Promise { + // 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 + } + }; + } } \ No newline at end of file diff --git a/src/infrastructure/adapters/automation/MockAutomationEngineAdapter.ts b/src/infrastructure/adapters/automation/MockAutomationEngineAdapter.ts index f10f4a35b..c905c78d4 100644 --- a/src/infrastructure/adapters/automation/MockAutomationEngineAdapter.ts +++ b/src/infrastructure/adapters/automation/MockAutomationEngineAdapter.ts @@ -3,6 +3,7 @@ import { HostedSessionConfig } from '../../../packages/domain/entities/HostedSes import { StepId } from '../../../packages/domain/value-objects/StepId'; import { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation'; import { ISessionRepository } from '../../../packages/application/ports/ISessionRepository'; +import { getStepName } from './selectors/IRacingSelectorMap'; export class MockAutomationEngineAdapter implements IAutomationEngine { private automationInterval: NodeJS.Timeout | null = null; @@ -58,11 +59,16 @@ export class MockAutomationEngineAdapter implements IAutomationEngine { const currentStep = session.currentStep; - // Execute current step (simulate browser automation) - if (typeof (this.browserAutomation as any).executeStep === 'function') { - await (this.browserAutomation as any).executeStep(currentStep, config); + // Execute current step using the browser automation + if (this.browserAutomation.executeStep) { + // Use real workflow automation with IRacingSelectorMap + const result = await this.browserAutomation.executeStep(currentStep, config as Record); + if (!result.success) { + console.error(`Step ${currentStep.value} (${getStepName(currentStep.value)}) failed:`, result.error); + // Continue anyway for now - in production we might want to pause or retry + } } else { - // Fallback to basic operations + // Fallback for adapters without executeStep (e.g., MockBrowserAutomationAdapter) await this.browserAutomation.navigateToPage(`step-${currentStep.value}`); } diff --git a/src/packages/application/ports/IBrowserAutomation.ts b/src/packages/application/ports/IBrowserAutomation.ts index 24e18233d..ba06490bd 100644 --- a/src/packages/application/ports/IBrowserAutomation.ts +++ b/src/packages/application/ports/IBrowserAutomation.ts @@ -5,6 +5,7 @@ import { ClickResult, WaitResult, ModalResult, + AutomationResult, } from './AutomationResults'; export interface IBrowserAutomation { @@ -14,6 +15,16 @@ export interface IBrowserAutomation { waitForElement(selector: string, maxWaitMs?: number): Promise; handleModal(stepId: StepId, action: string): Promise; + /** + * Execute a complete workflow step with all required browser operations. + * Uses IRacingSelectorMap to locate elements and performs appropriate actions. + * + * @param stepId - The step to execute (1-18) + * @param config - Session configuration with form field values + * @returns AutomationResult with success/failure and metadata + */ + executeStep?(stepId: StepId, config: Record): Promise; + connect?(): Promise; disconnect?(): Promise; isConnected?(): boolean;