working companion prototype
This commit is contained in:
45
packages/application/ports/IAuthenticationService.ts
Normal file
45
packages/application/ports/IAuthenticationService.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
|
||||
import { Result } from '../../shared/result/Result';
|
||||
|
||||
/**
|
||||
* Port for authentication services implementing zero-knowledge login.
|
||||
*
|
||||
* GridPilot never sees, stores, or transmits user credentials.
|
||||
* Authentication is handled by opening a visible browser window where
|
||||
* the user logs in directly with iRacing. GridPilot only observes
|
||||
* URL changes to detect successful authentication.
|
||||
*/
|
||||
export interface IAuthenticationService {
|
||||
/**
|
||||
* Check if user has a valid session without prompting login.
|
||||
* Navigates to a protected iRacing page and checks for login redirects.
|
||||
*
|
||||
* @returns Result containing the current authentication state
|
||||
*/
|
||||
checkSession(): Promise<Result<AuthenticationState>>;
|
||||
|
||||
/**
|
||||
* Open browser for user to login manually.
|
||||
* The browser window is visible so user can verify they're on the real iRacing site.
|
||||
* GridPilot waits for URL change indicating successful login.
|
||||
*
|
||||
* @returns Result indicating success (login complete) or failure (cancelled/timeout)
|
||||
*/
|
||||
initiateLogin(): Promise<Result<void>>;
|
||||
|
||||
/**
|
||||
* Clear the persistent session (logout).
|
||||
* Removes stored browser context and cookies.
|
||||
*
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
clearSession(): Promise<Result<void>>;
|
||||
|
||||
/**
|
||||
* Get current authentication state.
|
||||
* Returns cached state without making network requests.
|
||||
*
|
||||
* @returns The current AuthenticationState
|
||||
*/
|
||||
getState(): AuthenticationState;
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { StepId } from '../../domain/value-objects/StepId';
|
||||
import type { ImageTemplate } from '../../domain/value-objects/ImageTemplate';
|
||||
import type { ElementLocation, LoginDetectionResult, ScreenRegion } from '../../domain/value-objects/ScreenRegion';
|
||||
import {
|
||||
NavigationResult,
|
||||
FormFillResult,
|
||||
@@ -11,77 +9,35 @@ import {
|
||||
} from './AutomationResults';
|
||||
|
||||
/**
|
||||
* Screen capture result containing the captured image data.
|
||||
* Browser automation interface for Playwright-based automation.
|
||||
*
|
||||
* This interface defines the contract for browser automation using
|
||||
* standard DOM manipulation via Playwright. All automation is done
|
||||
* through browser DevTools protocol - no OS-level automation.
|
||||
*/
|
||||
export interface ScreenCaptureResult {
|
||||
success: boolean;
|
||||
data?: Buffer;
|
||||
width?: number;
|
||||
height?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Window information for browser window management.
|
||||
*/
|
||||
export interface WindowInfo {
|
||||
title: string;
|
||||
bounds: ScreenRegion;
|
||||
handle: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of window focus operation.
|
||||
*/
|
||||
export interface WindowFocusResult {
|
||||
success: boolean;
|
||||
window?: WindowInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended browser automation interface for OS-level screen automation.
|
||||
*
|
||||
* This interface extends the base IBrowserAutomation with screen recognition
|
||||
* capabilities for TOS-compliant automation that doesn't use browser DevTools.
|
||||
*
|
||||
* Key differences from browser-based automation:
|
||||
* - Uses image template matching instead of CSS selectors
|
||||
* - Works with screen coordinates instead of DOM elements
|
||||
* - Requires explicit window focus management
|
||||
* - No direct access to page content or JavaScript execution
|
||||
*/
|
||||
export interface IScreenAutomation {
|
||||
// ============================================
|
||||
// LEGACY BROWSER AUTOMATION METHODS
|
||||
// (Maintained for backward compatibility)
|
||||
// ============================================
|
||||
|
||||
export interface IBrowserAutomation {
|
||||
/**
|
||||
* Navigate to a URL using keyboard shortcuts (Cmd/Ctrl+L, type URL, Enter).
|
||||
* Requires browser window to be focused.
|
||||
* Navigate to a URL.
|
||||
*/
|
||||
navigateToPage(url: string): Promise<NavigationResult>;
|
||||
|
||||
/**
|
||||
* Fill a form field by selecting all text and typing new value.
|
||||
* Requires the field to already be focused.
|
||||
* Fill a form field by name or selector.
|
||||
*/
|
||||
fillFormField(fieldName: string, value: string): Promise<FormFillResult>;
|
||||
|
||||
/**
|
||||
* Click at a screen position (accepts coordinates or template ID).
|
||||
* Click an element by selector or action name.
|
||||
*/
|
||||
clickElement(target: string): Promise<ClickResult>;
|
||||
|
||||
/**
|
||||
* Wait for a condition (time-based in screen automation mode).
|
||||
* Wait for an element to appear.
|
||||
*/
|
||||
waitForElement(target: string, maxWaitMs?: number): Promise<WaitResult>;
|
||||
|
||||
/**
|
||||
* Handle modal dialogs using keyboard (Enter/Escape).
|
||||
* Handle modal dialogs.
|
||||
*/
|
||||
handleModal(stepId: StepId, action: string): Promise<ModalResult>;
|
||||
|
||||
@@ -91,90 +47,24 @@ export interface IScreenAutomation {
|
||||
executeStep?(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult>;
|
||||
|
||||
/**
|
||||
* Initialize the automation connection.
|
||||
* Initialize the browser connection.
|
||||
* Returns an AutomationResult indicating success or failure.
|
||||
*/
|
||||
connect?(): Promise<AutomationResult>;
|
||||
|
||||
/**
|
||||
* Clean up resources.
|
||||
* Clean up browser resources.
|
||||
*/
|
||||
disconnect?(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if automation is ready.
|
||||
* Check if browser is connected and ready.
|
||||
*/
|
||||
isConnected?(): boolean;
|
||||
|
||||
// ============================================
|
||||
// SCREEN AUTOMATION METHODS
|
||||
// (New methods for OS-level automation)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Detect login state by searching for known UI indicators on screen.
|
||||
* Uses template matching to find login-related elements.
|
||||
*
|
||||
* @returns LoginDetectionResult with confidence and detected indicators
|
||||
*/
|
||||
detectLoginState?(): Promise<LoginDetectionResult>;
|
||||
|
||||
/**
|
||||
* Find a UI element on screen using image template matching.
|
||||
*
|
||||
* @param template - The image template to search for
|
||||
* @returns ElementLocation if found, null if not found
|
||||
*/
|
||||
findElement?(template: ImageTemplate): Promise<ElementLocation | null>;
|
||||
|
||||
/**
|
||||
* Bring the browser window to the foreground.
|
||||
* Searches for windows matching a title pattern (e.g., "iRacing").
|
||||
*
|
||||
* @param titlePattern - Optional window title pattern to match
|
||||
* @returns WindowFocusResult indicating success/failure
|
||||
*/
|
||||
focusBrowserWindow?(titlePattern?: string): Promise<WindowFocusResult>;
|
||||
|
||||
/**
|
||||
* Capture a region of the screen.
|
||||
*
|
||||
* @param region - Optional region to capture (full screen if omitted)
|
||||
* @returns ScreenCaptureResult with image data
|
||||
*/
|
||||
captureScreen?(region?: ScreenRegion): Promise<ScreenCaptureResult>;
|
||||
|
||||
/**
|
||||
* Click on a found element location.
|
||||
*
|
||||
* @param location - The element location from findElement
|
||||
* @returns ClickResult indicating success/failure
|
||||
*/
|
||||
clickAtLocation?(location: ElementLocation): Promise<ClickResult>;
|
||||
|
||||
/**
|
||||
* Wait for a template to appear on screen.
|
||||
*
|
||||
* @param template - The image template to wait for
|
||||
* @param maxWaitMs - Maximum time to wait in milliseconds
|
||||
* @returns WaitResult with timing information
|
||||
*/
|
||||
waitForTemplate?(template: ImageTemplate, maxWaitMs?: number): Promise<WaitResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type alias for backward compatibility.
|
||||
* IBrowserAutomation is now a subset of IScreenAutomation.
|
||||
* @deprecated Use IBrowserAutomation directly. IScreenAutomation was for OS-level
|
||||
* automation which has been removed in favor of browser-only automation.
|
||||
*/
|
||||
export type IBrowserAutomation = Pick<
|
||||
IScreenAutomation,
|
||||
| 'navigateToPage'
|
||||
| 'fillFormField'
|
||||
| 'clickElement'
|
||||
| 'waitForElement'
|
||||
| 'handleModal'
|
||||
| 'executeStep'
|
||||
| 'connect'
|
||||
| 'disconnect'
|
||||
| 'isConnected'
|
||||
>;
|
||||
export type IScreenAutomation = IBrowserAutomation;
|
||||
22
packages/application/use-cases/CheckAuthenticationUseCase.ts
Normal file
22
packages/application/use-cases/CheckAuthenticationUseCase.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
|
||||
import { Result } from '../../shared/result/Result';
|
||||
import type { IAuthenticationService } from '../ports/IAuthenticationService';
|
||||
|
||||
/**
|
||||
* Use case for checking if the user has a valid iRacing session.
|
||||
*
|
||||
* This validates the session before automation starts, allowing
|
||||
* the system to prompt for re-authentication if needed.
|
||||
*/
|
||||
export class CheckAuthenticationUseCase {
|
||||
constructor(private readonly authService: IAuthenticationService) {}
|
||||
|
||||
/**
|
||||
* Execute the authentication check.
|
||||
*
|
||||
* @returns Result containing the current AuthenticationState
|
||||
*/
|
||||
async execute(): Promise<Result<AuthenticationState>> {
|
||||
return this.authService.checkSession();
|
||||
}
|
||||
}
|
||||
21
packages/application/use-cases/ClearSessionUseCase.ts
Normal file
21
packages/application/use-cases/ClearSessionUseCase.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Result } from '../../shared/result/Result';
|
||||
import type { IAuthenticationService } from '../ports/IAuthenticationService';
|
||||
|
||||
/**
|
||||
* Use case for clearing the user's session (logout).
|
||||
*
|
||||
* Removes stored browser context and cookies, effectively logging
|
||||
* the user out. The next automation attempt will require re-authentication.
|
||||
*/
|
||||
export class ClearSessionUseCase {
|
||||
constructor(private readonly authService: IAuthenticationService) {}
|
||||
|
||||
/**
|
||||
* Execute the session clearing.
|
||||
*
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
async execute(): Promise<Result<void>> {
|
||||
return this.authService.clearSession();
|
||||
}
|
||||
}
|
||||
23
packages/application/use-cases/InitiateLoginUseCase.ts
Normal file
23
packages/application/use-cases/InitiateLoginUseCase.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Result } from '../../shared/result/Result';
|
||||
import type { IAuthenticationService } from '../ports/IAuthenticationService';
|
||||
|
||||
/**
|
||||
* Use case for initiating the manual login flow.
|
||||
*
|
||||
* Opens a visible browser window where the user can log into iRacing directly.
|
||||
* GridPilot never sees the credentials - it only waits for the URL to change
|
||||
* indicating successful login.
|
||||
*/
|
||||
export class InitiateLoginUseCase {
|
||||
constructor(private readonly authService: IAuthenticationService) {}
|
||||
|
||||
/**
|
||||
* Execute the login flow.
|
||||
* Opens browser and waits for user to complete manual login.
|
||||
*
|
||||
* @returns Result indicating success (login complete) or failure (cancelled/timeout)
|
||||
*/
|
||||
async execute(): Promise<Result<void>> {
|
||||
return this.authService.initiateLogin();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ export interface HostedSessionConfig {
|
||||
maxDrivers: number;
|
||||
trackId: string;
|
||||
carIds: string[];
|
||||
/** Search term for car selection (alternative to carIds) */
|
||||
carSearch?: string;
|
||||
/** Search term for track selection (alternative to trackId) */
|
||||
trackSearch?: string;
|
||||
weatherType: 'static' | 'dynamic';
|
||||
timeOfDay: 'morning' | 'afternoon' | 'evening' | 'night';
|
||||
sessionDuration: number;
|
||||
|
||||
@@ -23,8 +23,7 @@ const STEP_DESCRIPTIONS: Record<number, string> = {
|
||||
14: 'Set Time of Day',
|
||||
15: 'Configure Weather',
|
||||
16: 'Set Race Options',
|
||||
17: 'Configure Team Driving',
|
||||
18: 'Track Conditions (STOP - Manual Submit Required)',
|
||||
17: 'Track Conditions (STOP - Manual Submit Required)',
|
||||
};
|
||||
|
||||
export class StepTransitionValidator {
|
||||
|
||||
18
packages/domain/value-objects/AuthenticationState.ts
Normal file
18
packages/domain/value-objects/AuthenticationState.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Value object representing the user's authentication state with iRacing.
|
||||
*
|
||||
* This is used to track whether the user has a valid session for automation
|
||||
* without GridPilot ever seeing or storing credentials (zero-knowledge design).
|
||||
*/
|
||||
export const AuthenticationState = {
|
||||
/** Authentication status has not yet been checked */
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
/** Valid session exists and is ready for automation */
|
||||
AUTHENTICATED: 'AUTHENTICATED',
|
||||
/** Session was valid but has expired, re-authentication required */
|
||||
EXPIRED: 'EXPIRED',
|
||||
/** User explicitly logged out, clearing the session */
|
||||
LOGGED_OUT: 'LOGGED_OUT',
|
||||
} as const;
|
||||
|
||||
export type AuthenticationState = typeof AuthenticationState[keyof typeof AuthenticationState];
|
||||
@@ -93,4 +93,6 @@ export const DEFAULT_CONFIDENCE = {
|
||||
LOW: 0.8,
|
||||
/** Minimum acceptable confidence */
|
||||
MINIMUM: 0.7,
|
||||
/** Very low confidence for testing/debugging template matching issues */
|
||||
DEBUG: 0.5,
|
||||
} as const;
|
||||
@@ -9,8 +9,8 @@ export class StepId {
|
||||
if (!Number.isInteger(value)) {
|
||||
throw new Error('StepId must be an integer');
|
||||
}
|
||||
if (value < 1 || value > 18) {
|
||||
throw new Error('StepId must be between 1 and 18');
|
||||
if (value < 1 || value > 17) {
|
||||
throw new Error('StepId must be between 1 and 17');
|
||||
}
|
||||
return new StepId(value);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export class StepId {
|
||||
}
|
||||
|
||||
isFinalStep(): boolean {
|
||||
return this._value === 18;
|
||||
return this._value === 17;
|
||||
}
|
||||
|
||||
next(): StepId {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { IAutomationEngine, ValidationResult } from '../../../application/ports/IAutomationEngine';
|
||||
import { HostedSessionConfig } from '../../../domain/entities/HostedSessionConfig';
|
||||
import { StepId } from '../../../domain/value-objects/StepId';
|
||||
import type { IBrowserAutomation } from '../../../application/ports/IScreenAutomation';
|
||||
import { ISessionRepository } from '../../../application/ports/ISessionRepository';
|
||||
import { getStepName } from './templates/IRacingTemplateMap';
|
||||
|
||||
/**
|
||||
* Real Automation Engine Adapter.
|
||||
*
|
||||
* Orchestrates the automation workflow by:
|
||||
* 1. Validating session configuration
|
||||
* 2. Executing each step using real browser automation
|
||||
* 3. Managing session state transitions
|
||||
*
|
||||
* This is a REAL implementation that uses actual automation,
|
||||
* not a mock. Currently delegates to deprecated nut.js adapters for
|
||||
* screen automation operations.
|
||||
*
|
||||
* @deprecated This adapter currently delegates to the deprecated NutJsAutomationAdapter.
|
||||
* Should be updated to use Playwright browser automation when available.
|
||||
* See docs/ARCHITECTURE.md for the updated automation strategy.
|
||||
*/
|
||||
export class AutomationEngineAdapter implements IAutomationEngine {
|
||||
private isRunning = false;
|
||||
private automationPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly browserAutomation: IBrowserAutomation,
|
||||
private readonly sessionRepository: ISessionRepository
|
||||
) {}
|
||||
|
||||
async validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult> {
|
||||
if (!config.sessionName || config.sessionName.trim() === '') {
|
||||
return { isValid: false, error: 'Session name is required' };
|
||||
}
|
||||
if (!config.trackId || config.trackId.trim() === '') {
|
||||
return { isValid: false, error: 'Track ID is required' };
|
||||
}
|
||||
if (!config.carIds || config.carIds.length === 0) {
|
||||
return { isValid: false, error: 'At least one car must be selected' };
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
async executeStep(stepId: StepId, config: HostedSessionConfig): Promise<void> {
|
||||
const sessions = await this.sessionRepository.findAll();
|
||||
const session = sessions[0];
|
||||
if (!session) {
|
||||
throw new Error('No active session found');
|
||||
}
|
||||
|
||||
// Start session if it's at step 1 and pending
|
||||
if (session.state.isPending() && stepId.value === 1) {
|
||||
session.start();
|
||||
await this.sessionRepository.update(session);
|
||||
|
||||
// Start automated progression
|
||||
this.startAutomation(config);
|
||||
}
|
||||
}
|
||||
|
||||
private startAutomation(config: HostedSessionConfig): void {
|
||||
if (this.isRunning) {
|
||||
return;
|
||||
}
|
||||
this.isRunning = true;
|
||||
this.automationPromise = this.runAutomationLoop(config);
|
||||
}
|
||||
|
||||
private async runAutomationLoop(config: HostedSessionConfig): Promise<void> {
|
||||
while (this.isRunning) {
|
||||
try {
|
||||
const sessions = await this.sessionRepository.findAll();
|
||||
const session = sessions[0];
|
||||
|
||||
if (!session || !session.state.isInProgress()) {
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStep = session.currentStep;
|
||||
|
||||
// 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 unknown as Record<string, unknown>);
|
||||
if (!result.success) {
|
||||
const errorMessage = `Step ${currentStep.value} (${getStepName(currentStep.value)}) failed: ${result.error}`;
|
||||
console.error(errorMessage);
|
||||
|
||||
// Stop automation and mark session as failed
|
||||
this.isRunning = false;
|
||||
|
||||
session.fail(errorMessage);
|
||||
await this.sessionRepository.update(session);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Fallback for adapters without executeStep
|
||||
await this.browserAutomation.navigateToPage(`step-${currentStep.value}`);
|
||||
}
|
||||
|
||||
// Transition to next step
|
||||
if (!currentStep.isFinalStep()) {
|
||||
session.transitionToStep(currentStep.next());
|
||||
await this.sessionRepository.update(session);
|
||||
|
||||
// If we just transitioned to the final step, execute it before stopping
|
||||
const nextStep = session.currentStep;
|
||||
if (nextStep.isFinalStep()) {
|
||||
// Execute final step handler
|
||||
if (this.browserAutomation.executeStep) {
|
||||
const result = await this.browserAutomation.executeStep(nextStep, config as unknown as Record<string, unknown>);
|
||||
if (!result.success) {
|
||||
const errorMessage = `Step ${nextStep.value} (${getStepName(nextStep.value)}) failed: ${result.error}`;
|
||||
console.error(errorMessage);
|
||||
// Don't try to fail terminal session - just log the error
|
||||
// Session is already in STOPPED_AT_STEP_18 state after transitionToStep()
|
||||
}
|
||||
}
|
||||
// Stop after final step
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Current step is already final - stop
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait before next iteration
|
||||
await this.delay(500);
|
||||
} catch (error) {
|
||||
console.error('Automation error:', error);
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
public stopAutomation(): void {
|
||||
this.isRunning = false;
|
||||
this.automationPromise = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import type { IFixtureServer } from './FixtureServer';
|
||||
|
||||
/**
|
||||
* Browser window configuration for E2E tests.
|
||||
*/
|
||||
export interface BrowserWindowConfig {
|
||||
/** X position of the window (default: 0) */
|
||||
x: number;
|
||||
/** Y position of the window (default: 0) */
|
||||
y: number;
|
||||
/** Window width (default: 1920) */
|
||||
width: number;
|
||||
/** Window height (default: 1080) */
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of browser launch operation.
|
||||
*/
|
||||
export interface BrowserLaunchResult {
|
||||
success: boolean;
|
||||
pid?: number;
|
||||
url?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* E2E Test Browser Launcher.
|
||||
*
|
||||
* Launches a real Chrome browser window for E2E testing with nut.js automation.
|
||||
* The browser displays HTML fixtures served by FixtureServer and is positioned
|
||||
* at a fixed location for deterministic template matching.
|
||||
*
|
||||
* IMPORTANT: This creates a REAL browser window on the user's screen.
|
||||
* It requires:
|
||||
* - Chrome/Chromium installed
|
||||
* - Display available (not headless)
|
||||
* - macOS permissions granted
|
||||
*/
|
||||
export class E2ETestBrowserLauncher {
|
||||
private browserProcess: ChildProcess | null = null;
|
||||
private windowConfig: BrowserWindowConfig;
|
||||
|
||||
constructor(
|
||||
private fixtureServer: IFixtureServer,
|
||||
windowConfig?: Partial<BrowserWindowConfig>
|
||||
) {
|
||||
this.windowConfig = {
|
||||
x: windowConfig?.x ?? 0,
|
||||
y: windowConfig?.y ?? 0,
|
||||
width: windowConfig?.width ?? 1920,
|
||||
height: windowConfig?.height ?? 1080,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch Chrome browser pointing to the fixture server.
|
||||
*
|
||||
* @param initialFixtureStep - Optional step number to navigate to initially
|
||||
* @returns BrowserLaunchResult indicating success or failure
|
||||
*/
|
||||
async launch(initialFixtureStep?: number): Promise<BrowserLaunchResult> {
|
||||
if (this.browserProcess) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Browser already launched. Call close() first.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.fixtureServer.isRunning()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Fixture server is not running. Start it before launching browser.',
|
||||
};
|
||||
}
|
||||
|
||||
const url = initialFixtureStep
|
||||
? this.fixtureServer.getFixtureUrl(initialFixtureStep)
|
||||
: `${this.getBaseUrl()}/all-steps.html`;
|
||||
|
||||
const chromePath = this.findChromePath();
|
||||
if (!chromePath) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Chrome/Chromium not found. Please install Chrome browser.',
|
||||
};
|
||||
}
|
||||
|
||||
const args = this.buildChromeArgs(url);
|
||||
|
||||
try {
|
||||
this.browserProcess = spawn(chromePath, args, {
|
||||
detached: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Give browser time to start
|
||||
await this.waitForBrowserStart();
|
||||
|
||||
if (this.browserProcess.pid) {
|
||||
return {
|
||||
success: true,
|
||||
pid: this.browserProcess.pid,
|
||||
url,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Browser process started but no PID available',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to launch browser: ${error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate the browser to a specific fixture step.
|
||||
*/
|
||||
async navigateToStep(stepNumber: number): Promise<void> {
|
||||
// Note: This would require browser automation to navigate
|
||||
// For now, we'll log the intent - actual navigation happens via nut.js
|
||||
const url = this.fixtureServer.getFixtureUrl(stepNumber);
|
||||
console.log(`[E2ETestBrowserLauncher] Navigate to step ${stepNumber}: ${url}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the browser process.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (!this.browserProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (!this.browserProcess) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up listener for process exit
|
||||
this.browserProcess.once('exit', () => {
|
||||
this.browserProcess = null;
|
||||
resolve();
|
||||
});
|
||||
|
||||
// Try graceful termination first
|
||||
this.browserProcess.kill('SIGTERM');
|
||||
|
||||
// Force kill after timeout
|
||||
setTimeout(() => {
|
||||
if (this.browserProcess) {
|
||||
this.browserProcess.kill('SIGKILL');
|
||||
this.browserProcess = null;
|
||||
resolve();
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if browser is running.
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.browserProcess !== null && !this.browserProcess.killed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the browser process PID.
|
||||
*/
|
||||
getPid(): number | undefined {
|
||||
return this.browserProcess?.pid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base URL of the fixture server.
|
||||
*/
|
||||
private getBaseUrl(): string {
|
||||
// Extract from fixture server
|
||||
return `http://localhost:3456`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Chrome/Chromium executable path.
|
||||
*/
|
||||
private findChromePath(): string | null {
|
||||
const platform = process.platform;
|
||||
|
||||
const paths: string[] = [];
|
||||
|
||||
if (platform === 'darwin') {
|
||||
paths.push(
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
paths.push(
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
);
|
||||
} else if (platform === 'win32') {
|
||||
const programFiles = process.env['PROGRAMFILES'] || 'C:\\Program Files';
|
||||
const programFilesX86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';
|
||||
const localAppData = process.env['LOCALAPPDATA'] || '';
|
||||
|
||||
paths.push(
|
||||
path.join(programFiles, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
path.join(programFilesX86, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
path.join(localAppData, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if any path exists
|
||||
const fs = require('fs');
|
||||
for (const chromePath of paths) {
|
||||
try {
|
||||
if (fs.existsSync(chromePath)) {
|
||||
return chromePath;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Chrome command line arguments.
|
||||
*/
|
||||
private buildChromeArgs(url: string): string[] {
|
||||
const { x, y, width, height } = this.windowConfig;
|
||||
|
||||
return [
|
||||
// Disable various Chrome features for cleaner automation
|
||||
'--disable-extensions',
|
||||
'--disable-plugins',
|
||||
'--disable-sync',
|
||||
'--disable-translate',
|
||||
'--disable-background-networking',
|
||||
'--disable-default-apps',
|
||||
'--disable-hang-monitor',
|
||||
'--disable-popup-blocking',
|
||||
'--disable-prompt-on-repost',
|
||||
'--disable-client-side-phishing-detection',
|
||||
'--disable-component-update',
|
||||
|
||||
// Window positioning
|
||||
`--window-position=${x},${y}`,
|
||||
`--window-size=${width},${height}`,
|
||||
|
||||
// Start with specific window settings
|
||||
'--start-maximized=false',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
|
||||
// Disable GPU for more consistent rendering in automation
|
||||
'--disable-gpu',
|
||||
|
||||
// Open DevTools disabled for cleaner screenshots
|
||||
// '--auto-open-devtools-for-tabs',
|
||||
|
||||
// Start with the URL
|
||||
url,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for browser to start and window to be ready.
|
||||
*/
|
||||
private async waitForBrowserStart(): Promise<void> {
|
||||
// Give Chrome time to:
|
||||
// 1. Start the process
|
||||
// 2. Create the window
|
||||
// 3. Load the initial page
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a browser launcher with default settings.
|
||||
*/
|
||||
export function createE2EBrowserLauncher(
|
||||
fixtureServer: IFixtureServer,
|
||||
config?: Partial<BrowserWindowConfig>
|
||||
): E2ETestBrowserLauncher {
|
||||
return new E2ETestBrowserLauncher(fixtureServer, config);
|
||||
}
|
||||
157
packages/infrastructure/adapters/automation/FixtureServer.ts
Normal file
157
packages/infrastructure/adapters/automation/FixtureServer.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface IFixtureServer {
|
||||
start(port?: number): Promise<{ url: string; port: number }>;
|
||||
stop(): Promise<void>;
|
||||
getFixtureUrl(stepNumber: number): string;
|
||||
isRunning(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step number to fixture file mapping.
|
||||
* Steps 2-18 map to the corresponding HTML fixture files.
|
||||
*/
|
||||
const STEP_TO_FIXTURE: Record<number, string> = {
|
||||
2: 'step-02-hosted-racing.html',
|
||||
3: 'step-03-create-race.html',
|
||||
4: 'step-04-race-information.html',
|
||||
5: 'step-05-server-details.html',
|
||||
6: 'step-06-set-admins.html',
|
||||
7: 'step-07-add-admin.html',
|
||||
8: 'step-08-time-limits.html',
|
||||
9: 'step-09-set-cars.html',
|
||||
10: 'step-10-add-car.html',
|
||||
11: 'step-11-set-car-classes.html',
|
||||
12: 'step-12-set-track.html',
|
||||
13: 'step-13-add-track.html',
|
||||
14: 'step-14-track-options.html',
|
||||
15: 'step-15-time-of-day.html',
|
||||
16: 'step-16-weather.html',
|
||||
17: 'step-17-race-options.html',
|
||||
18: 'step-18-track-conditions.html',
|
||||
};
|
||||
|
||||
export class FixtureServer implements IFixtureServer {
|
||||
private server: http.Server | null = null;
|
||||
private port: number = 3456;
|
||||
private fixturesPath: string;
|
||||
|
||||
constructor(fixturesPath?: string) {
|
||||
this.fixturesPath = fixturesPath ?? path.resolve(process.cwd(), 'resources/mock-fixtures');
|
||||
}
|
||||
|
||||
async start(port: number = 3456): Promise<{ url: string; port: number }> {
|
||||
if (this.server) {
|
||||
return { url: `http://localhost:${this.port}`, port: this.port };
|
||||
}
|
||||
|
||||
this.port = port;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = http.createServer((req, res) => {
|
||||
this.handleRequest(req, res);
|
||||
});
|
||||
|
||||
this.server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
// Try next port
|
||||
this.server = null;
|
||||
this.start(port + 1).then(resolve).catch(reject);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
this.server.listen(this.port, () => {
|
||||
resolve({ url: `http://localhost:${this.port}`, port: this.port });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server!.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
this.server = null;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getFixtureUrl(stepNumber: number): string {
|
||||
const fixture = STEP_TO_FIXTURE[stepNumber];
|
||||
if (!fixture) {
|
||||
return `http://localhost:${this.port}/`;
|
||||
}
|
||||
return `http://localhost:${this.port}/${fixture}`;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.server !== null;
|
||||
}
|
||||
|
||||
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
const urlPath = req.url || '/';
|
||||
const fileName = urlPath === '/' ? 'step-02-hosted-racing.html' : urlPath.replace(/^\//, '');
|
||||
const filePath = path.join(this.fixturesPath, fileName);
|
||||
|
||||
// Security check - prevent directory traversal
|
||||
if (!filePath.startsWith(this.fixturesPath)) {
|
||||
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||
res.end('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('Not Found');
|
||||
} else {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentTypes: Record<string, string> = {
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
const contentType = contentTypes[ext] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fixture filename for a given step number.
|
||||
*/
|
||||
export function getFixtureForStep(stepNumber: number): string | undefined {
|
||||
return STEP_TO_FIXTURE[stepNumber];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all step-to-fixture mappings.
|
||||
*/
|
||||
export function getAllStepFixtureMappings(): Record<number, string> {
|
||||
return { ...STEP_TO_FIXTURE };
|
||||
}
|
||||
210
packages/infrastructure/adapters/automation/IRacingSelectors.ts
Normal file
210
packages/infrastructure/adapters/automation/IRacingSelectors.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Selectors for the real iRacing website (members.iracing.com)
|
||||
* Uses text-based and ARIA selectors since the site uses React/Chakra UI
|
||||
* with dynamically generated class names.
|
||||
*
|
||||
* VERIFIED against real iRacing HTML captured 2024-11-23
|
||||
*/
|
||||
export const IRACING_SELECTORS = {
|
||||
// Login page
|
||||
login: {
|
||||
emailInput: '#username, input[name="username"], input[type="email"]',
|
||||
passwordInput: '#password, input[type="password"]',
|
||||
submitButton: 'button[type="submit"], button:has-text("Sign In")',
|
||||
},
|
||||
|
||||
// Hosted Racing page (Step 2)
|
||||
hostedRacing: {
|
||||
// Main "Create a Race" button on the hosted sessions page
|
||||
createRaceButton: 'button:has-text("Create a Race"), button[aria-label="Create a Race"]',
|
||||
hostedTab: '[aria-label*="Hosted" i], [role="tab"]:has-text("Hosted")',
|
||||
// Modal that appears after clicking "Create a Race"
|
||||
createRaceModal: '#confirm-create-race-modal, .modal:has-text("Create a Race")',
|
||||
// "New Race" button in the modal body (not footer) - two side-by-side buttons in a row
|
||||
// Verified from real iRacing HTML: buttons are <a class="btn btn-lg btn-info btn-block"> in modal-body
|
||||
newRaceButton: '#confirm-create-race-modal .modal-body a.btn:has-text("New Race"), #confirm-create-race-modal a.btn:has(.icon-wand)',
|
||||
lastSettingsButton: '#confirm-create-race-modal .modal-body a.btn:has-text("Last Settings"), #confirm-create-race-modal a.btn:has(.icon-servers)',
|
||||
},
|
||||
|
||||
// Common modal/wizard selectors - VERIFIED from real HTML
|
||||
wizard: {
|
||||
modal: '#create-race-modal, [role="dialog"], .modal.fade.in',
|
||||
modalDialog: '#create-race-modal-modal-dialog, .modal-dialog',
|
||||
modalContent: '#create-race-modal-modal-content, .modal-content',
|
||||
modalTitle: '[data-testid="modal-title"], .modal-title',
|
||||
// Wizard footer buttons - these are anchor tags styled as buttons
|
||||
// The "Next" button shows the name of the next step (e.g., "Server Details")
|
||||
nextButton: '.wizard-footer a.btn:not(.disabled):has(.icon-caret-right)',
|
||||
backButton: '.wizard-footer a.btn:has(.icon-caret-left):has-text("Back")',
|
||||
// Modal footer actions
|
||||
confirmButton: '.modal-footer a.btn-success, button:has-text("Confirm"), button:has-text("OK")',
|
||||
cancelButton: '.modal-footer a.btn-secondary:has-text("Back"), button:has-text("Cancel")',
|
||||
closeButton: '.modal-header a.close, [aria-label="Close"]',
|
||||
// Wizard sidebar navigation links - VERIFIED IDs from real HTML
|
||||
sidebarLinks: {
|
||||
raceInformation: '#wizard-sidebar-link-set-session-information',
|
||||
serverDetails: '#wizard-sidebar-link-set-server-details',
|
||||
admins: '#wizard-sidebar-link-set-admins',
|
||||
timeLimit: '#wizard-sidebar-link-set-time-limit',
|
||||
cars: '#wizard-sidebar-link-set-cars',
|
||||
track: '#wizard-sidebar-link-set-track',
|
||||
trackOptions: '#wizard-sidebar-link-set-track-options',
|
||||
timeOfDay: '#wizard-sidebar-link-set-time-of-day',
|
||||
weather: '#wizard-sidebar-link-set-weather',
|
||||
raceOptions: '#wizard-sidebar-link-set-race-options',
|
||||
trackConditions: '#wizard-sidebar-link-set-track-conditions',
|
||||
},
|
||||
// Wizard step containers (the visible step content)
|
||||
stepContainers: {
|
||||
raceInformation: '#set-session-information',
|
||||
serverDetails: '#set-server-details',
|
||||
admins: '#set-admins',
|
||||
timeLimit: '#set-time-limit',
|
||||
cars: '#set-cars',
|
||||
track: '#set-track',
|
||||
trackOptions: '#set-track-options',
|
||||
timeOfDay: '#set-time-of-day',
|
||||
weather: '#set-weather',
|
||||
raceOptions: '#set-race-options',
|
||||
trackConditions: '#set-track-conditions',
|
||||
},
|
||||
},
|
||||
|
||||
// Form fields - based on actual iRacing DOM structure
|
||||
fields: {
|
||||
textInput: 'input.form-control, .chakra-input, input[type="text"]',
|
||||
passwordInput: 'input[type="password"], input[maxlength="32"].form-control',
|
||||
textarea: 'textarea.form-control, .chakra-textarea, textarea',
|
||||
select: '.chakra-select, select.form-control, select',
|
||||
checkbox: '.chakra-checkbox, input[type="checkbox"], .switch-checkbox',
|
||||
slider: '.chakra-slider, input[type="range"]',
|
||||
toggle: '.switch input.switch-checkbox, .toggle-switch input',
|
||||
},
|
||||
|
||||
// Step-specific selectors - VERIFIED from real iRacing HTML structure
|
||||
steps: {
|
||||
// Step 3: Race Information - form structure inside #set-session-information
|
||||
// Form groups have labels followed by inputs
|
||||
sessionName: '#set-session-information .card-block .form-group:first-of-type input.form-control',
|
||||
sessionNameAlt: '#set-session-information input.form-control[type="text"]:not([maxlength])',
|
||||
password: '#set-session-information .card-block .form-group:nth-of-type(2) input.form-control',
|
||||
passwordAlt: '#set-session-information input.form-control[maxlength="32"]',
|
||||
description: '#set-session-information .card-block .form-group:last-of-type textarea.form-control',
|
||||
descriptionAlt: '#set-session-information textarea.form-control',
|
||||
// League racing toggle in Step 3
|
||||
leagueRacingToggle: '#set-session-information .switch-checkbox',
|
||||
|
||||
// Step 4: Server Details
|
||||
region: '#set-server-details select.form-control, #set-server-details [data-dropdown="region"]',
|
||||
startNow: '#set-server-details .switch-checkbox, #set-server-details input[type="checkbox"]',
|
||||
|
||||
// Step 5/6: Admins
|
||||
adminSearch: '.wizard-sidebar input[placeholder*="Search"], #set-admins input[placeholder*="Search"]',
|
||||
adminList: '#set-admins [data-list="admins"]',
|
||||
|
||||
// Step 7: Time Limits - Bootstrap-slider uses hidden input[type="text"] with id containing slider name
|
||||
// Also targets the visible slider handle for interaction
|
||||
practice: '#set-time-limit input[id*="practice"], #set-time-limit .slider input[type="text"], #set-time-limit [data-slider="practice"]',
|
||||
qualify: '#set-time-limit input[id*="qualify"], #set-time-limit .slider input[type="text"], #set-time-limit [data-slider="qualify"]',
|
||||
race: '#set-time-limit input[id*="race"], #set-time-limit .slider input[type="text"], #set-time-limit [data-slider="race"]',
|
||||
|
||||
// Step 8/9: Cars
|
||||
carSearch: '.wizard-sidebar input[placeholder*="Search"], #set-cars input[placeholder*="Search"], .modal input[placeholder*="Search"]',
|
||||
carList: '#set-cars [data-list="cars"]',
|
||||
// Add Car button - triggers the Add Car modal
|
||||
addCarButton: '#set-cars a.btn:has(.icon-plus), #set-cars button:has-text("Add"), #set-cars a.btn:has-text("Add")',
|
||||
// Add Car modal - appears after clicking Add Car button
|
||||
addCarModal: '#add-car-modal, .modal:has(input[placeholder*="Search"]):has-text("Car")',
|
||||
// Select button inside Add Car modal table row - clicking this adds the car immediately (no confirm step)
|
||||
// The "Select" button is an anchor styled as: a.btn.btn-block.btn-primary.btn-xs
|
||||
carSelectButton: '.modal table .btn-primary:has-text("Select"), .modal .btn-primary.btn-xs:has-text("Select"), .modal tbody .btn-primary',
|
||||
|
||||
// Step 10/11/12: Track
|
||||
trackSearch: '.wizard-sidebar input[placeholder*="Search"], #set-track input[placeholder*="Search"], .modal input[placeholder*="Search"]',
|
||||
trackList: '#set-track [data-list="tracks"]',
|
||||
// Add Track button - triggers the Add Track modal
|
||||
addTrackButton: '#set-track a.btn:has(.icon-plus), #set-track button:has-text("Add"), #set-track a.btn:has-text("Add"), #set-track button:has-text("Select"), #set-track a.btn:has-text("Select")',
|
||||
// Add Track modal - appears after clicking Add Track button
|
||||
addTrackModal: '#add-track-modal, .modal:has(input[placeholder*="Search"]):has-text("Track")',
|
||||
// Select button inside Add Track modal table row - clicking this selects the track immediately (no confirm step)
|
||||
// Prefer direct buttons (not dropdown toggles) for single-config tracks
|
||||
trackSelectButton: '.modal table a.btn.btn-primary.btn-xs:not(.dropdown-toggle)',
|
||||
// Dropdown toggle for multi-config tracks - opens a menu of track configurations
|
||||
trackSelectDropdown: '.modal table a.btn.btn-primary.btn-xs.dropdown-toggle',
|
||||
// First item in the dropdown menu for selecting track configuration
|
||||
trackSelectDropdownItem: '.dropdown-menu.show .dropdown-item:first-child, .dropdown-menu-lg .dropdown-item:first-child',
|
||||
|
||||
// Step 13: Track Options
|
||||
trackConfig: '#set-track-options select.form-control, #set-track-options [data-dropdown="trackConfig"]',
|
||||
|
||||
// Step 14: Time of Day - iRacing uses datetime picker (rdt class) and Bootstrap-slider components
|
||||
// The datetime picker has input.form-control, sliders have hidden input[type="text"]
|
||||
timeOfDay: '#set-time-of-day .rdt input.form-control, #set-time-of-day input[id*="slider"], #set-time-of-day .slider input[type="text"], #set-time-of-day [data-slider="timeOfDay"]',
|
||||
|
||||
// Step 15: Weather
|
||||
weatherType: '#set-weather select.form-control, #set-weather [data-dropdown="weatherType"]',
|
||||
// Temperature slider uses Bootstrap-slider with hidden input[type="text"]
|
||||
temperature: '#set-weather input[id*="slider"], #set-weather .slider input[type="text"], #set-weather [data-slider="temperature"]',
|
||||
|
||||
// Step 16: Race Options
|
||||
maxDrivers: '#set-race-options input[name*="maxDrivers"], #set-race-options input[type="number"]',
|
||||
rollingStart: '#set-race-options .switch-checkbox[name*="rolling"], #set-race-options input[type="checkbox"]',
|
||||
|
||||
// Step 17: Track Conditions (final step)
|
||||
trackState: '#set-track-conditions select.form-control, #set-track-conditions [data-dropdown="trackState"]',
|
||||
},
|
||||
|
||||
/**
|
||||
* DANGER ZONE - Selectors for checkout/payment buttons that should NEVER be clicked.
|
||||
* The automation must block any click on these selectors to prevent accidental purchases.
|
||||
* VERIFIED from real iRacing HTML - the checkout button has class btn-success with icon-cart
|
||||
*/
|
||||
BLOCKED_SELECTORS: {
|
||||
// Checkout/payment buttons - NEVER click these (verified from real HTML)
|
||||
checkout: 'a.btn-success:has(.icon-cart), a.btn:has-text("Check Out"), button:has-text("Check Out"), [data-testid*="checkout"]',
|
||||
purchase: 'button:has-text("Purchase"), a.btn:has-text("Purchase"), .chakra-button:has-text("Purchase"), button[aria-label="Purchase"]',
|
||||
buy: 'button:has-text("Buy"), a.btn:has-text("Buy Now"), button:has-text("Buy Now")',
|
||||
payment: 'button[type="submit"]:has-text("Submit Payment"), .payment-button, #checkout-button, button:has-text("Pay"), a.btn:has-text("Pay")',
|
||||
cart: 'a.btn:has(.icon-cart), button:has(.icon-cart), .btn-success:has(.icon-cart)',
|
||||
// Price labels that indicate purchase actions (e.g., "$0.50")
|
||||
priceAction: 'a.btn:has(.label-pill:has-text("$")), button:has(.label-pill:has-text("$")), .btn:has(.label-inverse:has-text("$"))',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Combined selector for all blocked/dangerous elements.
|
||||
* Use this to check if any selector targets a payment button.
|
||||
*/
|
||||
export const ALL_BLOCKED_SELECTORS = Object.values(IRACING_SELECTORS.BLOCKED_SELECTORS).join(', ');
|
||||
|
||||
/**
|
||||
* Keywords that indicate a dangerous/checkout action.
|
||||
* Used for text-based safety checks.
|
||||
*/
|
||||
export const BLOCKED_KEYWORDS = [
|
||||
'checkout',
|
||||
'check out',
|
||||
'purchase',
|
||||
'buy now',
|
||||
'buy',
|
||||
'pay',
|
||||
'submit payment',
|
||||
'add to cart',
|
||||
'proceed to payment',
|
||||
] as const;
|
||||
|
||||
export const IRACING_URLS = {
|
||||
hostedSessions: 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
|
||||
login: 'https://members.iracing.com/membersite/login.jsp',
|
||||
home: 'https://members.iracing.com',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Timeout values for real iRacing automation (in milliseconds)
|
||||
*/
|
||||
export const IRACING_TIMEOUTS = {
|
||||
navigation: 30000,
|
||||
elementWait: 15000,
|
||||
loginWait: 120000, // 2 minutes for manual login
|
||||
pageLoad: 20000,
|
||||
} as const;
|
||||
@@ -6,7 +6,8 @@ import { ISessionRepository } from '../../../application/ports/ISessionRepositor
|
||||
import { getStepName } from './templates/IRacingTemplateMap';
|
||||
|
||||
export class MockAutomationEngineAdapter implements IAutomationEngine {
|
||||
private automationInterval: NodeJS.Timeout | null = null;
|
||||
private isRunning = false;
|
||||
private automationPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly browserAutomation: IBrowserAutomation,
|
||||
@@ -44,16 +45,21 @@ export class MockAutomationEngineAdapter implements IAutomationEngine {
|
||||
}
|
||||
|
||||
private startAutomation(config: HostedSessionConfig): void {
|
||||
this.automationInterval = setInterval(async () => {
|
||||
if (this.isRunning) {
|
||||
return;
|
||||
}
|
||||
this.isRunning = true;
|
||||
this.automationPromise = this.runAutomationLoop(config);
|
||||
}
|
||||
|
||||
private async runAutomationLoop(config: HostedSessionConfig): Promise<void> {
|
||||
while (this.isRunning) {
|
||||
try {
|
||||
const sessions = await this.sessionRepository.findAll();
|
||||
const session = sessions[0];
|
||||
|
||||
if (!session || !session.state.isInProgress()) {
|
||||
if (this.automationInterval) {
|
||||
clearInterval(this.automationInterval);
|
||||
this.automationInterval = null;
|
||||
}
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,10 +74,7 @@ export class MockAutomationEngineAdapter implements IAutomationEngine {
|
||||
console.error(errorMessage);
|
||||
|
||||
// Stop automation and mark session as failed
|
||||
if (this.automationInterval) {
|
||||
clearInterval(this.automationInterval);
|
||||
this.automationInterval = null;
|
||||
}
|
||||
this.isRunning = false;
|
||||
|
||||
session.fail(errorMessage);
|
||||
await this.sessionRepository.update(session);
|
||||
@@ -82,31 +85,50 @@ export class MockAutomationEngineAdapter implements IAutomationEngine {
|
||||
await this.browserAutomation.navigateToPage(`step-${currentStep.value}`);
|
||||
}
|
||||
|
||||
// Transition to next step if not final
|
||||
// Transition to next step
|
||||
if (!currentStep.isFinalStep()) {
|
||||
session.transitionToStep(currentStep.next());
|
||||
await this.sessionRepository.update(session);
|
||||
} else {
|
||||
// Stop at step 18
|
||||
if (this.automationInterval) {
|
||||
clearInterval(this.automationInterval);
|
||||
this.automationInterval = null;
|
||||
|
||||
// If we just transitioned to the final step, execute it before stopping
|
||||
const nextStep = session.currentStep;
|
||||
if (nextStep.isFinalStep()) {
|
||||
// Execute final step handler
|
||||
if (this.browserAutomation.executeStep) {
|
||||
const result = await this.browserAutomation.executeStep(nextStep, config as unknown as Record<string, unknown>);
|
||||
if (!result.success) {
|
||||
const errorMessage = `Step ${nextStep.value} (${getStepName(nextStep.value)}) failed: ${result.error}`;
|
||||
console.error(errorMessage);
|
||||
// Don't try to fail terminal session - just log the error
|
||||
// Session is already in STOPPED_AT_STEP_18 state after transitionToStep()
|
||||
}
|
||||
}
|
||||
// Stop after final step
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Current step is already final - stop
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait before next iteration
|
||||
await this.delay(500);
|
||||
} catch (error) {
|
||||
console.error('Automation error:', error);
|
||||
if (this.automationInterval) {
|
||||
clearInterval(this.automationInterval);
|
||||
this.automationInterval = null;
|
||||
}
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
}, 500); // Execute each step every 500ms
|
||||
}
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
public stopAutomation(): void {
|
||||
if (this.automationInterval) {
|
||||
clearInterval(this.automationInterval);
|
||||
this.automationInterval = null;
|
||||
}
|
||||
this.isRunning = false;
|
||||
this.automationPromise = null;
|
||||
}
|
||||
}
|
||||
@@ -1,655 +0,0 @@
|
||||
import { mouse, keyboard, screen, Point, Key } from '@nut-tree-fork/nut-js';
|
||||
import type { IScreenAutomation, ScreenCaptureResult, WindowFocusResult } from '../../../application/ports/IScreenAutomation';
|
||||
import {
|
||||
AutomationResult,
|
||||
NavigationResult,
|
||||
FormFillResult,
|
||||
ClickResult,
|
||||
WaitResult,
|
||||
ModalResult,
|
||||
} from '../../../application/ports/AutomationResults';
|
||||
import { StepId } from '../../../domain/value-objects/StepId';
|
||||
import type { ImageTemplate } from '../../../domain/value-objects/ImageTemplate';
|
||||
import type { ElementLocation, LoginDetectionResult, ScreenRegion } from '../../../domain/value-objects/ScreenRegion';
|
||||
import type { ILogger } from '../../../application/ports/ILogger';
|
||||
import { NoOpLogAdapter } from '../logging/NoOpLogAdapter';
|
||||
import { ScreenRecognitionService } from './ScreenRecognitionService';
|
||||
import { TemplateMatchingService } from './TemplateMatchingService';
|
||||
import { WindowFocusService } from './WindowFocusService';
|
||||
import { getLoginIndicators, getLogoutIndicators, getStepTemplates, getStepName, type StepTemplates } from './templates/IRacingTemplateMap';
|
||||
|
||||
export interface NutJsConfig {
|
||||
mouseSpeed?: number;
|
||||
keyboardDelay?: number;
|
||||
screenResolution?: { width: number; height: number };
|
||||
defaultTimeout?: number;
|
||||
templatePath?: string;
|
||||
windowTitle?: string;
|
||||
}
|
||||
|
||||
export class NutJsAutomationAdapter implements IScreenAutomation {
|
||||
private config: Required<NutJsConfig>;
|
||||
private connected: boolean = false;
|
||||
private logger: ILogger;
|
||||
private screenRecognition: ScreenRecognitionService;
|
||||
private templateMatching: TemplateMatchingService;
|
||||
private windowFocus: WindowFocusService;
|
||||
|
||||
constructor(config: NutJsConfig = {}, logger?: ILogger) {
|
||||
this.config = {
|
||||
mouseSpeed: config.mouseSpeed ?? 1000,
|
||||
keyboardDelay: config.keyboardDelay ?? 50,
|
||||
screenResolution: config.screenResolution ?? { width: 1920, height: 1080 },
|
||||
defaultTimeout: config.defaultTimeout ?? 30000,
|
||||
templatePath: config.templatePath ?? './resources/templates/iracing',
|
||||
windowTitle: config.windowTitle ?? 'iRacing',
|
||||
};
|
||||
this.logger = logger ?? new NoOpLogAdapter();
|
||||
|
||||
mouse.config.mouseSpeed = this.config.mouseSpeed;
|
||||
keyboard.config.autoDelayMs = this.config.keyboardDelay;
|
||||
|
||||
this.screenRecognition = new ScreenRecognitionService(this.logger.child({ service: 'ScreenRecognition' }));
|
||||
this.templateMatching = new TemplateMatchingService(
|
||||
this.config.templatePath,
|
||||
this.logger.child({ service: 'TemplateMatching' })
|
||||
);
|
||||
this.windowFocus = new WindowFocusService(
|
||||
this.config.windowTitle,
|
||||
this.logger.child({ service: 'WindowFocus' })
|
||||
);
|
||||
}
|
||||
|
||||
async connect(): Promise<AutomationResult> {
|
||||
const startTime = Date.now();
|
||||
this.logger.info('Initializing nut.js OS-level automation');
|
||||
|
||||
try {
|
||||
const width = await screen.width();
|
||||
const height = await screen.height();
|
||||
this.connected = true;
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
this.logger.info('nut.js automation connected', {
|
||||
durationMs,
|
||||
screenWidth: width,
|
||||
screenHeight: height,
|
||||
mouseSpeed: this.config.mouseSpeed,
|
||||
keyboardDelay: this.config.keyboardDelay
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = `Screen access failed: ${error}`;
|
||||
this.logger.error('Failed to initialize nut.js', error instanceof Error ? error : new Error(errorMsg));
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
async navigateToPage(url: string): Promise<NavigationResult> {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
if (isMac) {
|
||||
await keyboard.pressKey(Key.LeftSuper, Key.L);
|
||||
await keyboard.releaseKey(Key.LeftSuper, Key.L);
|
||||
} else {
|
||||
await keyboard.pressKey(Key.LeftControl, Key.L);
|
||||
await keyboard.releaseKey(Key.LeftControl, Key.L);
|
||||
}
|
||||
|
||||
await this.delay(100);
|
||||
|
||||
await keyboard.type(url);
|
||||
|
||||
await keyboard.pressKey(Key.Enter);
|
||||
await keyboard.releaseKey(Key.Enter);
|
||||
|
||||
await this.delay(2000);
|
||||
|
||||
return { success: true, url, loadTime: Date.now() - startTime };
|
||||
} catch (error) {
|
||||
return { success: false, url, loadTime: 0, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async fillFormField(fieldName: string, value: string): Promise<FormFillResult> {
|
||||
try {
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
if (isMac) {
|
||||
await keyboard.pressKey(Key.LeftSuper, Key.A);
|
||||
await keyboard.releaseKey(Key.LeftSuper, Key.A);
|
||||
} else {
|
||||
await keyboard.pressKey(Key.LeftControl, Key.A);
|
||||
await keyboard.releaseKey(Key.LeftControl, Key.A);
|
||||
}
|
||||
|
||||
await this.delay(50);
|
||||
|
||||
await keyboard.type(value);
|
||||
|
||||
return { success: true, fieldName, valueSet: value };
|
||||
} catch (error) {
|
||||
return { success: false, fieldName, valueSet: '', error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async clickElement(target: string): Promise<ClickResult> {
|
||||
try {
|
||||
const point = this.parseTarget(target);
|
||||
await mouse.move([point]);
|
||||
await mouse.leftClick();
|
||||
return { success: true, target };
|
||||
} catch (error) {
|
||||
return { success: false, target, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async waitForElement(target: string, maxWaitMs?: number): Promise<WaitResult> {
|
||||
const startTime = Date.now();
|
||||
const timeout = maxWaitMs ?? this.config.defaultTimeout;
|
||||
|
||||
await this.delay(Math.min(1000, timeout));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
target,
|
||||
waitedMs: Date.now() - startTime,
|
||||
found: true,
|
||||
};
|
||||
}
|
||||
|
||||
async handleModal(stepId: StepId, action: string): Promise<ModalResult> {
|
||||
try {
|
||||
if (action === 'confirm') {
|
||||
await keyboard.pressKey(Key.Enter);
|
||||
await keyboard.releaseKey(Key.Enter);
|
||||
} else if (action === 'cancel') {
|
||||
await keyboard.pressKey(Key.Escape);
|
||||
await keyboard.releaseKey(Key.Escape);
|
||||
}
|
||||
|
||||
return { success: true, stepId: stepId.value, action };
|
||||
} catch (error) {
|
||||
return { success: false, stepId: stepId.value, action, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.logger.info('Disconnecting nut.js automation');
|
||||
this.connected = false;
|
||||
this.logger.debug('nut.js disconnected');
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
async detectLoginState(): Promise<LoginDetectionResult> {
|
||||
this.logger.debug('Detecting login state via template matching');
|
||||
|
||||
try {
|
||||
const detectedIndicators: string[] = [];
|
||||
let isLoggedIn = false;
|
||||
let highestConfidence = 0;
|
||||
|
||||
const loginIndicators = getLoginIndicators();
|
||||
for (const indicator of loginIndicators) {
|
||||
const location = await this.templateMatching.findElement(indicator);
|
||||
if (location) {
|
||||
detectedIndicators.push(indicator.id);
|
||||
isLoggedIn = true;
|
||||
highestConfidence = Math.max(highestConfidence, location.confidence);
|
||||
this.logger.debug('Login indicator found', { indicatorId: indicator.id, confidence: location.confidence });
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
const logoutIndicators = getLogoutIndicators();
|
||||
for (const indicator of logoutIndicators) {
|
||||
const location = await this.templateMatching.findElement(indicator);
|
||||
if (location) {
|
||||
detectedIndicators.push(indicator.id);
|
||||
highestConfidence = Math.max(highestConfidence, location.confidence);
|
||||
this.logger.debug('Logout indicator found', { indicatorId: indicator.id, confidence: location.confidence });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info('Login state detection complete', { isLoggedIn, detectedIndicators, confidence: highestConfidence });
|
||||
|
||||
return {
|
||||
isLoggedIn,
|
||||
confidence: highestConfidence,
|
||||
detectedIndicators,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = `Login state detection failed: ${error}`;
|
||||
this.logger.error('Login state detection failed', error instanceof Error ? error : new Error(errorMsg));
|
||||
|
||||
return {
|
||||
isLoggedIn: false,
|
||||
confidence: 0,
|
||||
detectedIndicators: [],
|
||||
error: errorMsg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async findElement(template: ImageTemplate): Promise<ElementLocation | null> {
|
||||
return this.templateMatching.findElement(template);
|
||||
}
|
||||
|
||||
async focusBrowserWindow(titlePattern?: string): Promise<WindowFocusResult> {
|
||||
return this.windowFocus.focusBrowserWindow(titlePattern);
|
||||
}
|
||||
|
||||
async captureScreen(region?: ScreenRegion): Promise<ScreenCaptureResult> {
|
||||
if (region) {
|
||||
return this.screenRecognition.captureRegion(region);
|
||||
}
|
||||
return this.screenRecognition.captureFullScreen();
|
||||
}
|
||||
|
||||
async clickAtLocation(location: ElementLocation): Promise<ClickResult> {
|
||||
try {
|
||||
const point = new Point(location.center.x, location.center.y);
|
||||
await mouse.move([point]);
|
||||
await mouse.leftClick();
|
||||
|
||||
this.logger.debug('Clicked at location', { x: location.center.x, y: location.center.y });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
target: `${location.center.x},${location.center.y}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
target: `${location.center.x},${location.center.y}`,
|
||||
error: String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async waitForTemplate(template: ImageTemplate, maxWaitMs?: number): Promise<WaitResult> {
|
||||
const timeout = maxWaitMs ?? this.config.defaultTimeout;
|
||||
const result = await this.templateMatching.waitForTemplate(template, timeout);
|
||||
|
||||
return {
|
||||
success: result.found,
|
||||
target: template.id,
|
||||
waitedMs: result.searchDurationMs,
|
||||
found: result.found,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
|
||||
const stepNumber = stepId.value;
|
||||
const startTime = Date.now();
|
||||
const stepName = getStepName(stepNumber);
|
||||
|
||||
this.logger.info('Executing step via OS-level automation', { stepId: stepNumber, stepName });
|
||||
|
||||
try {
|
||||
// Step 1: LOGIN - Skip (user handles manually)
|
||||
if (stepNumber === 1) {
|
||||
this.logger.debug('Skipping login step - user pre-authenticated', { stepId: stepNumber });
|
||||
return {
|
||||
success: true,
|
||||
metadata: {
|
||||
skipped: true,
|
||||
reason: 'User pre-authenticated',
|
||||
step: stepName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Step 18: TRACK_CONDITIONS - Safety stop before checkout
|
||||
if (stepNumber === 18) {
|
||||
this.logger.info('Safety stop at final step', { stepId: stepNumber });
|
||||
return {
|
||||
success: true,
|
||||
metadata: {
|
||||
step: stepName,
|
||||
safetyStop: true,
|
||||
message: 'Automation stopped at final step. User must review configuration and click checkout manually.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Steps 2-17: Real automation
|
||||
// 1. Focus browser window
|
||||
const focusResult = await this.windowFocus.focusBrowserWindow();
|
||||
if (!focusResult.success) {
|
||||
this.logger.warn('Failed to focus browser window, continuing anyway', { error: focusResult.error });
|
||||
}
|
||||
|
||||
// Small delay after focusing
|
||||
await this.delay(200);
|
||||
|
||||
// 2. Get templates for this step
|
||||
const stepTemplates = getStepTemplates(stepNumber);
|
||||
if (!stepTemplates) {
|
||||
this.logger.warn('No templates defined for step', { stepId: stepNumber, stepName });
|
||||
return {
|
||||
success: false,
|
||||
error: `No templates defined for step ${stepNumber} (${stepName})`,
|
||||
metadata: { step: stepName },
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Execute step-specific automation
|
||||
const result = await this.executeStepActions(stepNumber, stepName, stepTemplates, config);
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
this.logger.info('Step execution completed', { stepId: stepNumber, stepName, durationMs, success: result.success });
|
||||
|
||||
return {
|
||||
...result,
|
||||
metadata: {
|
||||
...result.metadata,
|
||||
step: stepName,
|
||||
durationMs,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
this.logger.error('Step execution failed', error instanceof Error ? error : new Error(String(error)), {
|
||||
stepId: stepNumber,
|
||||
stepName,
|
||||
durationMs
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: String(error),
|
||||
metadata: { step: stepName, durationMs },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute step-specific actions based on the step number.
|
||||
*/
|
||||
private async executeStepActions(
|
||||
stepNumber: number,
|
||||
stepName: string,
|
||||
templates: StepTemplates,
|
||||
config: Record<string, unknown>
|
||||
): Promise<AutomationResult> {
|
||||
switch (stepNumber) {
|
||||
// Step 2: HOSTED_RACING - Click "Create a Race" button
|
||||
case 2:
|
||||
return this.executeClickStep(templates, 'createRace', 'Navigate to hosted racing');
|
||||
|
||||
// Step 3: CREATE_RACE - Confirm race creation modal
|
||||
case 3:
|
||||
return this.executeClickStep(templates, 'confirm', 'Confirm race creation');
|
||||
|
||||
// Step 4: RACE_INFORMATION - Fill session name and details, then click next
|
||||
case 4:
|
||||
return this.executeFormStep(templates, config, [
|
||||
{ field: 'sessionName', configKey: 'sessionName' },
|
||||
{ field: 'password', configKey: 'sessionPassword' },
|
||||
{ field: 'description', configKey: 'description' },
|
||||
], 'next');
|
||||
|
||||
// Step 5: SERVER_DETAILS - Configure server settings, then click next
|
||||
case 5:
|
||||
return this.executeFormStep(templates, config, [
|
||||
{ field: 'region', configKey: 'serverRegion' },
|
||||
], 'next');
|
||||
|
||||
// Step 6: SET_ADMINS - Modal step for adding admins
|
||||
case 6:
|
||||
return this.executeModalStep(templates, config, 'adminName', 'next');
|
||||
|
||||
// Step 7: TIME_LIMITS - Fill time fields
|
||||
case 7:
|
||||
return this.executeFormStep(templates, config, [
|
||||
{ field: 'practice', configKey: 'practiceLength' },
|
||||
{ field: 'qualify', configKey: 'qualifyLength' },
|
||||
{ field: 'race', configKey: 'raceLength' },
|
||||
], 'next');
|
||||
|
||||
// Step 8: SET_CARS - Click add car button
|
||||
case 8:
|
||||
return this.executeClickStep(templates, 'addCar', 'Open car selection');
|
||||
|
||||
// Step 9: ADD_CAR - Modal for car selection
|
||||
case 9:
|
||||
return this.executeModalStep(templates, config, 'carName', 'select');
|
||||
|
||||
// Step 10: SET_CAR_CLASSES - Configure car classes
|
||||
case 10:
|
||||
return this.executeFormStep(templates, config, [
|
||||
{ field: 'class', configKey: 'carClass' },
|
||||
], 'next');
|
||||
|
||||
// Step 11: SET_TRACK - Click add track button
|
||||
case 11:
|
||||
return this.executeClickStep(templates, 'addTrack', 'Open track selection');
|
||||
|
||||
// Step 12: ADD_TRACK - Modal for track selection
|
||||
case 12:
|
||||
return this.executeModalStep(templates, config, 'trackName', 'select');
|
||||
|
||||
// Step 13: TRACK_OPTIONS - Configure track options
|
||||
case 13:
|
||||
return this.executeFormStep(templates, config, [
|
||||
{ field: 'config', configKey: 'trackConfig' },
|
||||
], 'next');
|
||||
|
||||
// Step 14: TIME_OF_DAY - Configure time settings
|
||||
case 14:
|
||||
return this.executeFormStep(templates, config, [
|
||||
{ field: 'time', configKey: 'timeOfDay' },
|
||||
{ field: 'date', configKey: 'raceDate' },
|
||||
], 'next');
|
||||
|
||||
// Step 15: WEATHER - Configure weather settings
|
||||
case 15:
|
||||
return this.executeFormStep(templates, config, [
|
||||
{ field: 'weather', configKey: 'weatherType' },
|
||||
{ field: 'temperature', configKey: 'temperature' },
|
||||
], 'next');
|
||||
|
||||
// Step 16: RACE_OPTIONS - Configure race options
|
||||
case 16:
|
||||
return this.executeFormStep(templates, config, [
|
||||
{ field: 'maxDrivers', configKey: 'maxDrivers' },
|
||||
{ field: 'rollingStart', configKey: 'rollingStart' },
|
||||
], 'next');
|
||||
|
||||
// Step 17: TEAM_DRIVING - Configure team settings
|
||||
case 17:
|
||||
return this.executeFormStep(templates, config, [
|
||||
{ field: 'teamDriving', configKey: 'teamDriving' },
|
||||
], 'next');
|
||||
|
||||
default:
|
||||
this.logger.warn('Unhandled step number', { stepNumber, stepName });
|
||||
return {
|
||||
success: false,
|
||||
error: `No automation handler for step ${stepNumber} (${stepName})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a simple click step - find and click a button.
|
||||
*/
|
||||
private async executeClickStep(
|
||||
templates: StepTemplates,
|
||||
buttonKey: string,
|
||||
actionDescription: string
|
||||
): Promise<AutomationResult> {
|
||||
const buttonTemplate = templates.buttons[buttonKey];
|
||||
|
||||
if (!buttonTemplate) {
|
||||
this.logger.warn('Button template not defined', { buttonKey });
|
||||
return {
|
||||
success: false,
|
||||
error: `Button template '${buttonKey}' not defined for this step`,
|
||||
};
|
||||
}
|
||||
|
||||
// Find the button on screen
|
||||
const location = await this.templateMatching.findElement(buttonTemplate);
|
||||
|
||||
if (!location) {
|
||||
this.logger.warn('Button not found on screen', {
|
||||
buttonKey,
|
||||
templateId: buttonTemplate.id,
|
||||
description: buttonTemplate.description
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: `Button '${buttonKey}' not found on screen (template: ${buttonTemplate.id})`,
|
||||
metadata: { templateId: buttonTemplate.id, action: actionDescription },
|
||||
};
|
||||
}
|
||||
|
||||
// Click the button
|
||||
const clickResult = await this.clickAtLocation(location);
|
||||
|
||||
if (!clickResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to click button '${buttonKey}': ${clickResult.error}`,
|
||||
metadata: { templateId: buttonTemplate.id, action: actionDescription },
|
||||
};
|
||||
}
|
||||
|
||||
// Small delay after clicking
|
||||
await this.delay(300);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
metadata: {
|
||||
action: actionDescription,
|
||||
templateId: buttonTemplate.id,
|
||||
clickLocation: location.center,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a form step - fill fields and click next.
|
||||
*/
|
||||
private async executeFormStep(
|
||||
templates: StepTemplates,
|
||||
config: Record<string, unknown>,
|
||||
fieldMappings: Array<{ field: string; configKey: string }>,
|
||||
nextButtonKey: string
|
||||
): Promise<AutomationResult> {
|
||||
const filledFields: string[] = [];
|
||||
const skippedFields: string[] = [];
|
||||
|
||||
// Process each field mapping
|
||||
for (const mapping of fieldMappings) {
|
||||
const fieldTemplate = templates.fields?.[mapping.field];
|
||||
const configValue = config[mapping.configKey];
|
||||
|
||||
// Skip if no value provided in config
|
||||
if (configValue === undefined || configValue === null || configValue === '') {
|
||||
skippedFields.push(mapping.field);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if no template defined
|
||||
if (!fieldTemplate) {
|
||||
this.logger.debug('Field template not defined, skipping', { field: mapping.field });
|
||||
skippedFields.push(mapping.field);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the field on screen
|
||||
const location = await this.templateMatching.findElement(fieldTemplate);
|
||||
|
||||
if (!location) {
|
||||
this.logger.warn('Field not found on screen', {
|
||||
field: mapping.field,
|
||||
templateId: fieldTemplate.id
|
||||
});
|
||||
skippedFields.push(mapping.field);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Click the field to focus it
|
||||
await this.clickAtLocation(location);
|
||||
await this.delay(100);
|
||||
|
||||
// Fill the field
|
||||
const fillResult = await this.fillFormField(mapping.field, String(configValue));
|
||||
|
||||
if (fillResult.success) {
|
||||
filledFields.push(mapping.field);
|
||||
} else {
|
||||
this.logger.warn('Failed to fill field', { field: mapping.field, error: fillResult.error });
|
||||
skippedFields.push(mapping.field);
|
||||
}
|
||||
|
||||
await this.delay(100);
|
||||
}
|
||||
|
||||
// Click the next button
|
||||
const nextResult = await this.executeClickStep(templates, nextButtonKey, 'Proceed to next step');
|
||||
|
||||
return {
|
||||
success: nextResult.success,
|
||||
error: nextResult.error,
|
||||
metadata: {
|
||||
...nextResult.metadata,
|
||||
filledFields,
|
||||
skippedFields,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a modal step - interact with a modal dialog.
|
||||
*/
|
||||
private async executeModalStep(
|
||||
templates: StepTemplates,
|
||||
config: Record<string, unknown>,
|
||||
searchConfigKey: string,
|
||||
confirmButtonKey: string
|
||||
): Promise<AutomationResult> {
|
||||
// If modal has search input and we have a search value, use it
|
||||
if (templates.modal?.searchInput) {
|
||||
const searchValue = config[searchConfigKey];
|
||||
|
||||
if (searchValue && typeof searchValue === 'string') {
|
||||
const searchLocation = await this.templateMatching.findElement(templates.modal.searchInput);
|
||||
|
||||
if (searchLocation) {
|
||||
await this.clickAtLocation(searchLocation);
|
||||
await this.delay(100);
|
||||
await this.fillFormField('search', searchValue);
|
||||
await this.delay(500); // Wait for search results
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Click the confirm/select button
|
||||
return this.executeClickStep(templates, confirmButtonKey, 'Confirm modal selection');
|
||||
}
|
||||
|
||||
private parseTarget(target: string): Point {
|
||||
if (target.includes(',')) {
|
||||
const [x, y] = target.split(',').map(Number);
|
||||
return new Point(x, y);
|
||||
}
|
||||
return new Point(
|
||||
Math.floor(this.config.screenResolution.width / 2),
|
||||
Math.floor(this.config.screenResolution.height / 2)
|
||||
);
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { systemPreferences, shell } from 'electron';
|
||||
import type { ILogger } from '../../../application/ports/ILogger';
|
||||
import { NoOpLogAdapter } from '../logging/NoOpLogAdapter';
|
||||
|
||||
/**
|
||||
* Permission status for macOS automation permissions.
|
||||
*/
|
||||
export interface PermissionStatus {
|
||||
accessibility: boolean;
|
||||
screenRecording: boolean;
|
||||
platform: NodeJS.Platform;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a permission check operation.
|
||||
*/
|
||||
export interface PermissionCheckResult {
|
||||
granted: boolean;
|
||||
status: PermissionStatus;
|
||||
missingPermissions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for checking and managing macOS permissions required for automation.
|
||||
*
|
||||
* On macOS, the following permissions are required:
|
||||
* - Accessibility: Required for keyboard/mouse control (nut.js)
|
||||
* - Screen Recording: Required for screen capture and window detection
|
||||
*
|
||||
* On other platforms, permissions are assumed to be granted.
|
||||
*/
|
||||
export class PermissionService {
|
||||
private logger: ILogger;
|
||||
private cachedStatus: PermissionStatus | null = null;
|
||||
|
||||
constructor(logger?: ILogger) {
|
||||
this.logger = logger ?? new NoOpLogAdapter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all required permissions are granted.
|
||||
*
|
||||
* @returns PermissionCheckResult with status of each permission
|
||||
*/
|
||||
async checkPermissions(): Promise<PermissionCheckResult> {
|
||||
const status = await this.getPermissionStatus();
|
||||
const missingPermissions: string[] = [];
|
||||
|
||||
if (!status.accessibility) {
|
||||
missingPermissions.push('Accessibility');
|
||||
}
|
||||
if (!status.screenRecording) {
|
||||
missingPermissions.push('Screen Recording');
|
||||
}
|
||||
|
||||
const granted = missingPermissions.length === 0;
|
||||
|
||||
this.logger.info('Permission check completed', {
|
||||
granted,
|
||||
accessibility: status.accessibility,
|
||||
screenRecording: status.screenRecording,
|
||||
platform: status.platform,
|
||||
});
|
||||
|
||||
return {
|
||||
granted,
|
||||
status,
|
||||
missingPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current permission status for each required permission.
|
||||
*/
|
||||
async getPermissionStatus(): Promise<PermissionStatus> {
|
||||
const platform = process.platform;
|
||||
|
||||
// On non-macOS platforms, assume permissions are granted
|
||||
if (platform !== 'darwin') {
|
||||
this.logger.debug('Non-macOS platform, assuming permissions granted', { platform });
|
||||
return {
|
||||
accessibility: true,
|
||||
screenRecording: true,
|
||||
platform,
|
||||
};
|
||||
}
|
||||
|
||||
const accessibility = this.checkAccessibilityPermission();
|
||||
const screenRecording = this.checkScreenRecordingPermission();
|
||||
|
||||
this.cachedStatus = {
|
||||
accessibility,
|
||||
screenRecording,
|
||||
platform,
|
||||
};
|
||||
|
||||
this.logger.debug('Permission status retrieved', {
|
||||
accessibility: this.cachedStatus.accessibility,
|
||||
screenRecording: this.cachedStatus.screenRecording,
|
||||
platform: this.cachedStatus.platform,
|
||||
});
|
||||
|
||||
return this.cachedStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Accessibility permission is granted.
|
||||
* Uses systemPreferences.isTrustedAccessibilityClient on macOS.
|
||||
*/
|
||||
private checkAccessibilityPermission(): boolean {
|
||||
try {
|
||||
// isTrustedAccessibilityClient checks if the app has Accessibility permission
|
||||
// Pass false to just check without prompting the user
|
||||
const isTrusted = systemPreferences.isTrustedAccessibilityClient(false);
|
||||
this.logger.debug('Accessibility permission check', { isTrusted });
|
||||
return isTrusted;
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to check Accessibility permission', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Screen Recording permission is granted.
|
||||
* Uses systemPreferences.getMediaAccessStatus on macOS.
|
||||
*/
|
||||
private checkScreenRecordingPermission(): boolean {
|
||||
try {
|
||||
// getMediaAccessStatus with 'screen' checks Screen Recording permission
|
||||
const status = systemPreferences.getMediaAccessStatus('screen');
|
||||
const isGranted = status === 'granted';
|
||||
this.logger.debug('Screen Recording permission check', { status, isGranted });
|
||||
return isGranted;
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to check Screen Recording permission', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request Accessibility permission by prompting the user.
|
||||
* This will show a system dialog asking for permission.
|
||||
*
|
||||
* @returns true if permission was granted after prompt
|
||||
*/
|
||||
requestAccessibilityPermission(): boolean {
|
||||
if (process.platform !== 'darwin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Pass true to prompt the user if not already trusted
|
||||
const isTrusted = systemPreferences.isTrustedAccessibilityClient(true);
|
||||
this.logger.info('Accessibility permission requested', { isTrusted });
|
||||
return isTrusted;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to request Accessibility permission',
|
||||
error instanceof Error ? error : new Error(String(error)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open System Preferences to the Security & Privacy pane.
|
||||
*
|
||||
* @param pane - Which pane to open: 'accessibility' or 'screenRecording'
|
||||
*/
|
||||
async openSystemPreferences(pane: 'accessibility' | 'screenRecording'): Promise<void> {
|
||||
if (process.platform !== 'darwin') {
|
||||
this.logger.debug('Not on macOS, cannot open System Preferences');
|
||||
return;
|
||||
}
|
||||
|
||||
// macOS System Preferences URLs
|
||||
const urls: Record<string, string> = {
|
||||
accessibility: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility',
|
||||
screenRecording: 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
|
||||
};
|
||||
|
||||
const url = urls[pane];
|
||||
if (url) {
|
||||
this.logger.info('Opening System Preferences', { pane, url });
|
||||
await shell.openExternal(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open System Preferences to show all missing permissions.
|
||||
* Opens Accessibility pane first if that permission is missing.
|
||||
*/
|
||||
async openPermissionsSettings(): Promise<void> {
|
||||
const status = this.cachedStatus ?? await this.getPermissionStatus();
|
||||
|
||||
if (!status.accessibility) {
|
||||
await this.openSystemPreferences('accessibility');
|
||||
} else if (!status.screenRecording) {
|
||||
await this.openSystemPreferences('screenRecording');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached permission status without re-checking.
|
||||
* Returns null if permissions haven't been checked yet.
|
||||
*/
|
||||
getCachedStatus(): PermissionStatus | null {
|
||||
return this.cachedStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached permission status.
|
||||
* Next call to getPermissionStatus will re-check permissions.
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedStatus = null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,124 +0,0 @@
|
||||
import { screen, Region } from '@nut-tree-fork/nut-js';
|
||||
import type { ScreenRegion } from '../../../domain/value-objects/ScreenRegion';
|
||||
import type { ScreenCaptureResult } from '../../../application/ports/IScreenAutomation';
|
||||
import type { ILogger } from '../../../application/ports/ILogger';
|
||||
import { NoOpLogAdapter } from '../logging/NoOpLogAdapter';
|
||||
|
||||
/**
|
||||
* Service for capturing screen regions using nut.js.
|
||||
* Provides screen capture functionality for template matching and visual automation.
|
||||
*/
|
||||
export class ScreenRecognitionService {
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(logger?: ILogger) {
|
||||
this.logger = logger ?? new NoOpLogAdapter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the entire screen.
|
||||
* @returns ScreenCaptureResult with image buffer data
|
||||
*/
|
||||
async captureFullScreen(): Promise<ScreenCaptureResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const width = await screen.width();
|
||||
const height = await screen.height();
|
||||
|
||||
this.logger.debug('Capturing full screen', { width, height });
|
||||
|
||||
const image = await screen.grab();
|
||||
const data = await image.toRGB();
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
this.logger.debug('Screen capture completed', { durationMs, width, height });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: Buffer.from(data.data),
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = `Screen capture failed: ${error}`;
|
||||
this.logger.error('Screen capture failed', error instanceof Error ? error : new Error(errorMsg));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a specific region of the screen.
|
||||
* @param region - The rectangular region to capture
|
||||
* @returns ScreenCaptureResult with image buffer data
|
||||
*/
|
||||
async captureRegion(region: ScreenRegion): Promise<ScreenCaptureResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
this.logger.debug('Capturing screen region', { region });
|
||||
|
||||
const nutRegion = new Region(region.x, region.y, region.width, region.height);
|
||||
const image = await screen.grabRegion(nutRegion);
|
||||
const data = await image.toRGB();
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
this.logger.debug('Region capture completed', { durationMs, region });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: Buffer.from(data.data),
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = `Region capture failed: ${error}`;
|
||||
this.logger.error('Region capture failed', error instanceof Error ? error : new Error(errorMsg), { region });
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current screen dimensions.
|
||||
* @returns Object with width and height, or null on error
|
||||
*/
|
||||
async getScreenDimensions(): Promise<{ width: number; height: number } | null> {
|
||||
try {
|
||||
const width = await screen.width();
|
||||
const height = await screen.height();
|
||||
return { width, height };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get screen dimensions', error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ScreenRegion to nut.js Region.
|
||||
* Utility method for internal use.
|
||||
*/
|
||||
toNutRegion(region: ScreenRegion): Region {
|
||||
return new Region(region.x, region.y, region.width, region.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert nut.js Region to ScreenRegion.
|
||||
* Utility method for internal use.
|
||||
*/
|
||||
fromNutRegion(nutRegion: Region): ScreenRegion {
|
||||
return {
|
||||
x: nutRegion.left,
|
||||
y: nutRegion.top,
|
||||
width: nutRegion.width,
|
||||
height: nutRegion.height,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { AuthenticationState } from '../../../domain/value-objects/AuthenticationState';
|
||||
import type { ILogger } from '../../../application/ports/ILogger';
|
||||
|
||||
interface Cookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
interface StorageState {
|
||||
cookies: Cookie[];
|
||||
origins: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Known iRacing session cookie names to look for.
|
||||
* These are the primary authentication indicators.
|
||||
*/
|
||||
const IRACING_SESSION_COOKIES = [
|
||||
'irsso_members',
|
||||
'authtoken_members',
|
||||
'irsso',
|
||||
'authtoken',
|
||||
];
|
||||
|
||||
/**
|
||||
* iRacing domain patterns to match cookies against.
|
||||
*/
|
||||
const IRACING_DOMAINS = [
|
||||
'iracing.com',
|
||||
'.iracing.com',
|
||||
'members.iracing.com',
|
||||
];
|
||||
|
||||
const EXPIRY_BUFFER_SECONDS = 300;
|
||||
|
||||
export class SessionCookieStore {
|
||||
private readonly storagePath: string;
|
||||
private logger?: ILogger;
|
||||
|
||||
constructor(userDataDir: string, logger?: ILogger) {
|
||||
this.storagePath = path.join(userDataDir, 'session-state.json');
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
|
||||
if (this.logger) {
|
||||
if (level === 'error') {
|
||||
this.logger.error(message, undefined, context);
|
||||
} else {
|
||||
this.logger[level](message, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getPath(): string {
|
||||
return this.storagePath;
|
||||
}
|
||||
|
||||
async read(): Promise<StorageState | null> {
|
||||
try {
|
||||
const content = await fs.readFile(this.storagePath, 'utf-8');
|
||||
return JSON.parse(content) as StorageState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async write(state: StorageState): Promise<void> {
|
||||
await fs.writeFile(this.storagePath, JSON.stringify(state, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
async delete(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(this.storagePath);
|
||||
} catch {
|
||||
// File may not exist, ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate cookies and determine authentication state.
|
||||
*
|
||||
* Looks for iRacing session cookies by checking:
|
||||
* 1. Domain matches iRacing patterns
|
||||
* 2. Cookie name matches known session cookie names OR
|
||||
* 3. Any cookie from members.iracing.com domain (fallback)
|
||||
*/
|
||||
validateCookies(cookies: Cookie[]): AuthenticationState {
|
||||
// Log all cookies for debugging
|
||||
this.log('debug', 'Validating cookies', {
|
||||
totalCookies: cookies.length,
|
||||
cookieNames: cookies.map(c => ({ name: c.name, domain: c.domain }))
|
||||
});
|
||||
|
||||
// Filter cookies from iRacing domains
|
||||
const iracingDomainCookies = cookies.filter(c =>
|
||||
IRACING_DOMAINS.some(domain =>
|
||||
c.domain === domain || c.domain.endsWith(domain)
|
||||
)
|
||||
);
|
||||
|
||||
this.log('debug', 'iRacing domain cookies found', {
|
||||
count: iracingDomainCookies.length,
|
||||
cookies: iracingDomainCookies.map(c => ({
|
||||
name: c.name,
|
||||
domain: c.domain,
|
||||
expires: c.expires,
|
||||
expiresDate: new Date(c.expires * 1000).toISOString()
|
||||
}))
|
||||
});
|
||||
|
||||
// Look for known session cookies first
|
||||
const knownSessionCookies = iracingDomainCookies.filter(c =>
|
||||
IRACING_SESSION_COOKIES.some(name =>
|
||||
c.name.toLowerCase() === name.toLowerCase() ||
|
||||
c.name.toLowerCase().includes(name.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
// If no known session cookies, check for any auth-like cookies from members domain
|
||||
const authCookies = knownSessionCookies.length > 0
|
||||
? knownSessionCookies
|
||||
: iracingDomainCookies.filter(c =>
|
||||
c.domain.includes('members') &&
|
||||
(c.name.toLowerCase().includes('auth') ||
|
||||
c.name.toLowerCase().includes('sso') ||
|
||||
c.name.toLowerCase().includes('session') ||
|
||||
c.name.toLowerCase().includes('token'))
|
||||
);
|
||||
|
||||
this.log('debug', 'Authentication cookies identified', {
|
||||
knownSessionCookiesCount: knownSessionCookies.length,
|
||||
authCookiesCount: authCookies.length,
|
||||
cookies: authCookies.map(c => ({ name: c.name, domain: c.domain }))
|
||||
});
|
||||
|
||||
if (authCookies.length === 0) {
|
||||
// Last resort: if we have ANY cookies from members.iracing.com, consider it potentially valid
|
||||
const membersCookies = iracingDomainCookies.filter(c =>
|
||||
c.domain.includes('members.iracing.com') || c.domain === '.iracing.com'
|
||||
);
|
||||
|
||||
if (membersCookies.length > 0) {
|
||||
this.log('info', 'No known auth cookies found, but members domain cookies exist', {
|
||||
count: membersCookies.length,
|
||||
cookies: membersCookies.map(c => c.name)
|
||||
});
|
||||
|
||||
// Check expiry on any of these cookies
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const hasValidCookie = membersCookies.some(c =>
|
||||
c.expires === -1 || c.expires === 0 || c.expires > (now + EXPIRY_BUFFER_SECONDS)
|
||||
);
|
||||
|
||||
return hasValidCookie
|
||||
? AuthenticationState.AUTHENTICATED
|
||||
: AuthenticationState.EXPIRED;
|
||||
}
|
||||
|
||||
this.log('info', 'No iRacing authentication cookies found');
|
||||
return AuthenticationState.UNKNOWN;
|
||||
}
|
||||
|
||||
// Check if any auth cookie is still valid (not expired)
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const validCookies = authCookies.filter(c => {
|
||||
// Handle session cookies (expires = -1 or 0) and persistent cookies
|
||||
const isSession = c.expires === -1 || c.expires === 0;
|
||||
const isNotExpired = c.expires > (now + EXPIRY_BUFFER_SECONDS);
|
||||
return isSession || isNotExpired;
|
||||
});
|
||||
|
||||
this.log('debug', 'Cookie expiry check', {
|
||||
now,
|
||||
validCookiesCount: validCookies.length,
|
||||
cookies: authCookies.map(c => ({
|
||||
name: c.name,
|
||||
expires: c.expires,
|
||||
isValid: c.expires === -1 || c.expires === 0 || c.expires > (now + EXPIRY_BUFFER_SECONDS)
|
||||
}))
|
||||
});
|
||||
|
||||
if (validCookies.length > 0) {
|
||||
this.log('info', 'Valid iRacing session cookies found', { count: validCookies.length });
|
||||
return AuthenticationState.AUTHENTICATED;
|
||||
}
|
||||
|
||||
this.log('info', 'iRacing session cookies found but all expired');
|
||||
return AuthenticationState.EXPIRED;
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
import { screen, imageResource, Region } from '@nut-tree-fork/nut-js';
|
||||
import type { ImageTemplate } from '../../../domain/value-objects/ImageTemplate';
|
||||
import type { ElementLocation, ScreenRegion, Point } from '../../../domain/value-objects/ScreenRegion';
|
||||
import type { ILogger } from '../../../application/ports/ILogger';
|
||||
import { NoOpLogAdapter } from '../logging/NoOpLogAdapter';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Result of a template matching operation.
|
||||
*/
|
||||
export interface TemplateMatchResult {
|
||||
found: boolean;
|
||||
location?: ElementLocation;
|
||||
searchDurationMs: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for finding UI elements on screen using image template matching.
|
||||
* Uses nut.js screen.find() with @nut-tree-fork/template-matcher for image comparison.
|
||||
*/
|
||||
export class TemplateMatchingService {
|
||||
private logger: ILogger;
|
||||
private templateBasePath: string;
|
||||
private loadedTemplates: Map<string, string> = new Map();
|
||||
|
||||
constructor(templateBasePath?: string, logger?: ILogger) {
|
||||
this.logger = logger ?? new NoOpLogAdapter();
|
||||
this.templateBasePath = templateBasePath ?? './resources/templates';
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an element on screen using image template matching.
|
||||
* @param template - The image template to search for
|
||||
* @returns ElementLocation if found, null if not found
|
||||
*/
|
||||
async findElement(template: ImageTemplate): Promise<ElementLocation | null> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const fullPath = this.resolveTemplatePath(template.imagePath);
|
||||
|
||||
if (!this.templateExists(fullPath)) {
|
||||
this.logger.warn('Template image not found, skipping search', {
|
||||
templateId: template.id,
|
||||
imagePath: fullPath,
|
||||
description: template.description
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.debug('Searching for template', {
|
||||
templateId: template.id,
|
||||
confidence: template.confidence,
|
||||
searchRegion: template.searchRegion
|
||||
});
|
||||
|
||||
const templateImage = await imageResource(fullPath);
|
||||
|
||||
// Set confidence threshold for this search
|
||||
screen.config.confidence = template.confidence;
|
||||
|
||||
let foundRegion: Region;
|
||||
|
||||
if (template.searchRegion) {
|
||||
// Search within a specific region for better performance
|
||||
const searchArea = new Region(
|
||||
template.searchRegion.x,
|
||||
template.searchRegion.y,
|
||||
template.searchRegion.width,
|
||||
template.searchRegion.height
|
||||
);
|
||||
foundRegion = await screen.find(templateImage, { searchRegion: searchArea });
|
||||
} else {
|
||||
// Search entire screen
|
||||
foundRegion = await screen.find(templateImage);
|
||||
}
|
||||
|
||||
const location = this.regionToElementLocation(foundRegion, template.confidence);
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
this.logger.debug('Template found', {
|
||||
templateId: template.id,
|
||||
location: location.center,
|
||||
confidence: location.confidence,
|
||||
durationMs
|
||||
});
|
||||
|
||||
return location;
|
||||
} catch (error) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
// nut.js throws when template is not found - this is expected behavior
|
||||
if (String(error).includes('No match')) {
|
||||
this.logger.debug('Template not found on screen', {
|
||||
templateId: template.id,
|
||||
durationMs
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.error('Template matching error', error instanceof Error ? error : new Error(String(error)), {
|
||||
templateId: template.id,
|
||||
durationMs
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find multiple occurrences of a template on screen.
|
||||
* @param template - The image template to search for
|
||||
* @returns Array of ElementLocation for all matches
|
||||
*/
|
||||
async findAllElements(template: ImageTemplate): Promise<ElementLocation[]> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const fullPath = this.resolveTemplatePath(template.imagePath);
|
||||
|
||||
if (!this.templateExists(fullPath)) {
|
||||
this.logger.warn('Template image not found', { templateId: template.id, imagePath: fullPath });
|
||||
return [];
|
||||
}
|
||||
|
||||
const templateImage = await imageResource(fullPath);
|
||||
screen.config.confidence = template.confidence;
|
||||
|
||||
const foundRegions = await screen.findAll(templateImage);
|
||||
|
||||
const locations = foundRegions.map(region => this.regionToElementLocation(region, template.confidence));
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
this.logger.debug('Found multiple templates', {
|
||||
templateId: template.id,
|
||||
count: locations.length,
|
||||
durationMs
|
||||
});
|
||||
|
||||
return locations;
|
||||
} catch (error) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
if (String(error).includes('No match')) {
|
||||
this.logger.debug('No templates found on screen', { templateId: template.id, durationMs });
|
||||
return [];
|
||||
}
|
||||
|
||||
this.logger.error('Template matching error', error instanceof Error ? error : new Error(String(error)), {
|
||||
templateId: template.id,
|
||||
durationMs
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a template to appear on screen.
|
||||
* @param template - The image template to wait for
|
||||
* @param maxWaitMs - Maximum time to wait in milliseconds
|
||||
* @param pollIntervalMs - How often to check for the template
|
||||
* @returns TemplateMatchResult with timing information
|
||||
*/
|
||||
async waitForTemplate(
|
||||
template: ImageTemplate,
|
||||
maxWaitMs: number = 10000,
|
||||
pollIntervalMs: number = 500
|
||||
): Promise<TemplateMatchResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.debug('Waiting for template', {
|
||||
templateId: template.id,
|
||||
maxWaitMs,
|
||||
pollIntervalMs
|
||||
});
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
const location = await this.findElement(template);
|
||||
|
||||
if (location) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
this.logger.debug('Template appeared', { templateId: template.id, durationMs });
|
||||
|
||||
return {
|
||||
found: true,
|
||||
location,
|
||||
searchDurationMs: durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
await this.delay(pollIntervalMs);
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
this.logger.debug('Template did not appear within timeout', {
|
||||
templateId: template.id,
|
||||
durationMs,
|
||||
maxWaitMs
|
||||
});
|
||||
|
||||
return {
|
||||
found: false,
|
||||
searchDurationMs: durationMs,
|
||||
error: `Template '${template.id}' not found within ${maxWaitMs}ms`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a template exists on screen without returning location.
|
||||
* Useful for boolean state checks.
|
||||
*/
|
||||
async isTemplateVisible(template: ImageTemplate): Promise<boolean> {
|
||||
const location = await this.findElement(template);
|
||||
return location !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a template path relative to the base template directory.
|
||||
*/
|
||||
private resolveTemplatePath(imagePath: string): string {
|
||||
if (path.isAbsolute(imagePath)) {
|
||||
return imagePath;
|
||||
}
|
||||
return path.join(this.templateBasePath, imagePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a template file exists.
|
||||
*/
|
||||
private templateExists(fullPath: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(fullPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert nut.js Region to ElementLocation.
|
||||
*/
|
||||
private regionToElementLocation(region: Region, confidence: number): ElementLocation {
|
||||
const bounds: ScreenRegion = {
|
||||
x: region.left,
|
||||
y: region.top,
|
||||
width: region.width,
|
||||
height: region.height,
|
||||
};
|
||||
|
||||
const center: Point = {
|
||||
x: region.left + Math.floor(region.width / 2),
|
||||
y: region.top + Math.floor(region.height / 2),
|
||||
};
|
||||
|
||||
return {
|
||||
center,
|
||||
bounds,
|
||||
confidence,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility delay function.
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured template base path.
|
||||
*/
|
||||
getTemplateBasePath(): string {
|
||||
return this.templateBasePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the template base path.
|
||||
*/
|
||||
setTemplateBasePath(basePath: string): void {
|
||||
this.templateBasePath = basePath;
|
||||
this.loadedTemplates.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import { getWindows, getActiveWindow } from '@nut-tree-fork/nut-js';
|
||||
import type { ScreenRegion } from '../../../domain/value-objects/ScreenRegion';
|
||||
import type { WindowInfo, WindowFocusResult } from '../../../application/ports/IScreenAutomation';
|
||||
import type { ILogger } from '../../../application/ports/ILogger';
|
||||
import { NoOpLogAdapter } from '../logging/NoOpLogAdapter';
|
||||
|
||||
/**
|
||||
* Service for managing browser window focus and enumeration.
|
||||
* Uses nut.js window management APIs to find and focus browser windows.
|
||||
*/
|
||||
export class WindowFocusService {
|
||||
private logger: ILogger;
|
||||
private defaultTitlePattern: string;
|
||||
|
||||
constructor(defaultTitlePattern?: string, logger?: ILogger) {
|
||||
this.logger = logger ?? new NoOpLogAdapter();
|
||||
this.defaultTitlePattern = defaultTitlePattern ?? 'iRacing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and focus a browser window matching the title pattern.
|
||||
* @param titlePattern - Pattern to match in window title (default: 'iRacing')
|
||||
* @returns WindowFocusResult with window info if successful
|
||||
*/
|
||||
async focusBrowserWindow(titlePattern?: string): Promise<WindowFocusResult> {
|
||||
const pattern = titlePattern ?? this.defaultTitlePattern;
|
||||
|
||||
try {
|
||||
this.logger.debug('Searching for browser window', { titlePattern: pattern });
|
||||
|
||||
const windows = await getWindows();
|
||||
|
||||
for (const windowObj of windows) {
|
||||
try {
|
||||
const title = await windowObj.getTitle();
|
||||
|
||||
if (title.toLowerCase().includes(pattern.toLowerCase())) {
|
||||
this.logger.debug('Found matching window', { title });
|
||||
|
||||
await windowObj.focus();
|
||||
|
||||
// Get window bounds after focusing
|
||||
const region = await windowObj.getRegion();
|
||||
const bounds: ScreenRegion = {
|
||||
x: region.left,
|
||||
y: region.top,
|
||||
width: region.width,
|
||||
height: region.height,
|
||||
};
|
||||
|
||||
const windowInfo: WindowInfo = {
|
||||
title,
|
||||
bounds,
|
||||
handle: 0, // Window objects don't expose raw handles
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
this.logger.info('Browser window focused', { title, bounds });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
window: windowInfo,
|
||||
};
|
||||
}
|
||||
} catch (windowError) {
|
||||
// Skip windows that can't be accessed
|
||||
this.logger.debug('Could not access window', { error: String(windowError) });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.warn('No matching browser window found', { titlePattern: pattern });
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `No window found matching pattern: ${pattern}`,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = `Window focus failed: ${error}`;
|
||||
this.logger.error('Window focus failed', error instanceof Error ? error : new Error(errorMsg));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active window.
|
||||
* @returns WindowInfo for the active window, or null if unable to determine
|
||||
*/
|
||||
async getActiveWindow(): Promise<WindowInfo | null> {
|
||||
try {
|
||||
const activeWindow = await getActiveWindow();
|
||||
const title = await activeWindow.getTitle();
|
||||
const region = await activeWindow.getRegion();
|
||||
|
||||
const bounds: ScreenRegion = {
|
||||
x: region.left,
|
||||
y: region.top,
|
||||
width: region.width,
|
||||
height: region.height,
|
||||
};
|
||||
|
||||
return {
|
||||
title,
|
||||
bounds,
|
||||
handle: 0, // Active window doesn't expose handle directly
|
||||
isActive: true,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get active window', error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all visible windows.
|
||||
* @returns Array of WindowInfo for all accessible windows
|
||||
*/
|
||||
async listWindows(): Promise<WindowInfo[]> {
|
||||
const result: WindowInfo[] = [];
|
||||
|
||||
try {
|
||||
const windows = await getWindows();
|
||||
const activeWindow = await getActiveWindow();
|
||||
const activeTitle = await activeWindow.getTitle();
|
||||
|
||||
for (const windowObj of windows) {
|
||||
try {
|
||||
const title = await windowObj.getTitle();
|
||||
const region = await windowObj.getRegion();
|
||||
|
||||
const bounds: ScreenRegion = {
|
||||
x: region.left,
|
||||
y: region.top,
|
||||
width: region.width,
|
||||
height: region.height,
|
||||
};
|
||||
|
||||
result.push({
|
||||
title,
|
||||
bounds,
|
||||
handle: 0, // Window objects don't expose raw handles
|
||||
isActive: title === activeTitle,
|
||||
});
|
||||
} catch {
|
||||
// Skip inaccessible windows
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to list windows', error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a window matching the pattern is currently visible.
|
||||
* @param titlePattern - Pattern to match in window title
|
||||
* @returns true if a matching window exists
|
||||
*/
|
||||
async isWindowVisible(titlePattern?: string): Promise<boolean> {
|
||||
const pattern = titlePattern ?? this.defaultTitlePattern;
|
||||
|
||||
try {
|
||||
const windows = await getWindows();
|
||||
|
||||
for (const windowObj of windows) {
|
||||
try {
|
||||
const title = await windowObj.getTitle();
|
||||
|
||||
if (title.toLowerCase().includes(pattern.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bounds of a window matching the pattern.
|
||||
* Useful for targeted screen capture.
|
||||
* @param titlePattern - Pattern to match in window title
|
||||
* @returns ScreenRegion of the window, or null if not found
|
||||
*/
|
||||
async getWindowBounds(titlePattern?: string): Promise<ScreenRegion | null> {
|
||||
const pattern = titlePattern ?? this.defaultTitlePattern;
|
||||
|
||||
try {
|
||||
const windows = await getWindows();
|
||||
|
||||
for (const windowObj of windows) {
|
||||
try {
|
||||
const title = await windowObj.getTitle();
|
||||
|
||||
if (title.toLowerCase().includes(pattern.toLowerCase())) {
|
||||
const region = await windowObj.getRegion();
|
||||
|
||||
return {
|
||||
x: region.left,
|
||||
y: region.top,
|
||||
width: region.width,
|
||||
height: region.height,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get window bounds', error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default title pattern for window searches.
|
||||
*/
|
||||
setDefaultTitlePattern(pattern: string): void {
|
||||
this.defaultTitlePattern = pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current default title pattern.
|
||||
*/
|
||||
getDefaultTitlePattern(): string {
|
||||
return this.defaultTitlePattern;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,21 @@
|
||||
/**
|
||||
* Automation adapters for OS-level screen automation.
|
||||
* Automation adapters for browser automation.
|
||||
*
|
||||
* Exports:
|
||||
* - MockBrowserAutomationAdapter: Mock adapter for testing
|
||||
* - NutJsAutomationAdapter: OS-level automation via nut.js
|
||||
* - PermissionService: macOS permission checking for automation
|
||||
* - ScreenRecognitionService: Image template matching for UI detection
|
||||
* - TemplateMatchingService: Low-level template matching operations
|
||||
* - WindowFocusService: Window management for automation
|
||||
* - IRacingTemplateMap: Image templates for iRacing UI elements
|
||||
* - PlaywrightAutomationAdapter: Browser automation via Playwright
|
||||
* - FixtureServer: HTTP server for serving fixture HTML files
|
||||
* - IRacingTemplateMap: Template map for iRacing UI elements
|
||||
*/
|
||||
|
||||
// Adapters
|
||||
export { MockBrowserAutomationAdapter } from './MockBrowserAutomationAdapter';
|
||||
export { NutJsAutomationAdapter } from './NutJsAutomationAdapter';
|
||||
export type { NutJsConfig } from './NutJsAutomationAdapter';
|
||||
export { PlaywrightAutomationAdapter } from './PlaywrightAutomationAdapter';
|
||||
export type { PlaywrightConfig } from './PlaywrightAutomationAdapter';
|
||||
|
||||
// Services
|
||||
export { PermissionService } from './PermissionService';
|
||||
export type { PermissionStatus, PermissionCheckResult } from './PermissionService';
|
||||
export { ScreenRecognitionService } from './ScreenRecognitionService';
|
||||
export { TemplateMatchingService } from './TemplateMatchingService';
|
||||
export { WindowFocusService } from './WindowFocusService';
|
||||
export { FixtureServer, getFixtureForStep, getAllStepFixtureMappings } from './FixtureServer';
|
||||
export type { IFixtureServer } from './FixtureServer';
|
||||
|
||||
// Template map and utilities
|
||||
export {
|
||||
|
||||
@@ -77,6 +77,7 @@ const TEMPLATE_PATHS = {
|
||||
},
|
||||
2: {
|
||||
hostedRacingTab: 'step02-hosted/hosted-racing-tab.png',
|
||||
// Using 1x template - will be scaled by 2x for Retina displays
|
||||
createRaceButton: 'step02-hosted/create-race-button.png',
|
||||
sessionList: 'step02-hosted/session-list.png',
|
||||
},
|
||||
@@ -281,13 +282,15 @@ export const IRacingTemplateMap: IRacingTemplateMapType = {
|
||||
},
|
||||
|
||||
// Step 2: HOSTED_RACING
|
||||
// NOTE: Using DEBUG confidence (0.5) temporarily to test template matching
|
||||
// after fixing the Retina scaling issue (DISPLAY_SCALE_FACTOR=1)
|
||||
2: {
|
||||
indicators: [
|
||||
createImageTemplate(
|
||||
'step2-hosted-tab',
|
||||
TEMPLATE_PATHS.steps[2].hostedRacingTab,
|
||||
'Hosted racing tab indicator',
|
||||
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
|
||||
{ confidence: DEFAULT_CONFIDENCE.DEBUG }
|
||||
),
|
||||
],
|
||||
buttons: {
|
||||
@@ -295,7 +298,7 @@ export const IRacingTemplateMap: IRacingTemplateMapType = {
|
||||
'step2-create-race',
|
||||
TEMPLATE_PATHS.steps[2].createRaceButton,
|
||||
'Create a Race button',
|
||||
{ confidence: DEFAULT_CONFIDENCE.HIGH }
|
||||
{ confidence: DEFAULT_CONFIDENCE.DEBUG }
|
||||
),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,13 +10,61 @@
|
||||
* - NODE_ENV=test → MockBrowserAutomation → N/A → N/A
|
||||
*/
|
||||
|
||||
export type AutomationMode = 'production' | 'test';
|
||||
export type AutomationMode = 'production' | 'development' | 'test';
|
||||
|
||||
/**
|
||||
* @deprecated Use AutomationMode instead. Will be removed in future version.
|
||||
*/
|
||||
export type LegacyAutomationMode = 'dev' | 'production' | 'mock';
|
||||
|
||||
/**
|
||||
* Retry configuration for element finding operations.
|
||||
*/
|
||||
export interface RetryConfig {
|
||||
/** Maximum number of retry attempts (default: 3) */
|
||||
maxRetries: number;
|
||||
/** Initial delay in milliseconds before first retry (default: 500) */
|
||||
baseDelayMs: number;
|
||||
/** Maximum delay in milliseconds between retries (default: 5000) */
|
||||
maxDelayMs: number;
|
||||
/** Multiplier for exponential backoff (default: 2.0) */
|
||||
backoffMultiplier: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timing configuration for automation operations.
|
||||
*/
|
||||
export interface TimingConfig {
|
||||
/** Wait time for page to load after opening browser (default: 5000) */
|
||||
pageLoadWaitMs: number;
|
||||
/** Delay between sequential actions (default: 200) */
|
||||
interActionDelayMs: number;
|
||||
/** Delay after clicking an element (default: 300) */
|
||||
postClickDelayMs: number;
|
||||
/** Delay before starting step execution (default: 100) */
|
||||
preStepDelayMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default retry configuration values.
|
||||
*/
|
||||
export const DEFAULT_RETRY_CONFIG: RetryConfig = {
|
||||
maxRetries: 3,
|
||||
baseDelayMs: 500,
|
||||
maxDelayMs: 5000,
|
||||
backoffMultiplier: 2.0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default timing configuration values.
|
||||
*/
|
||||
export const DEFAULT_TIMING_CONFIG: TimingConfig = {
|
||||
pageLoadWaitMs: 5000,
|
||||
interActionDelayMs: 200,
|
||||
postClickDelayMs: 300,
|
||||
preStepDelayMs: 100,
|
||||
};
|
||||
|
||||
export interface AutomationEnvironmentConfig {
|
||||
mode: AutomationMode;
|
||||
|
||||
@@ -27,6 +75,10 @@ export interface AutomationEnvironmentConfig {
|
||||
windowTitle?: string;
|
||||
templatePath?: string;
|
||||
confidence?: number;
|
||||
/** Retry configuration for element finding */
|
||||
retry?: Partial<RetryConfig>;
|
||||
/** Timing configuration for waits */
|
||||
timing?: Partial<TimingConfig>;
|
||||
};
|
||||
|
||||
/** Default timeout for automation operations in milliseconds */
|
||||
@@ -60,8 +112,9 @@ export function getAutomationMode(): AutomationMode {
|
||||
}
|
||||
|
||||
const nodeEnv = process.env.NODE_ENV;
|
||||
// Both production and development use real OS automation
|
||||
if (nodeEnv === 'production' || nodeEnv === 'development') return 'production';
|
||||
// Map NODE_ENV to AutomationMode
|
||||
if (nodeEnv === 'production') return 'production';
|
||||
if (nodeEnv === 'development') return 'development';
|
||||
return 'test';
|
||||
}
|
||||
|
||||
@@ -89,8 +142,20 @@ export function loadAutomationConfig(): AutomationEnvironmentConfig {
|
||||
mouseSpeed: parseIntSafe(process.env.NUTJS_MOUSE_SPEED, 1000),
|
||||
keyboardDelay: parseIntSafe(process.env.NUTJS_KEYBOARD_DELAY, 50),
|
||||
windowTitle: process.env.IRACING_WINDOW_TITLE || 'iRacing',
|
||||
templatePath: process.env.TEMPLATE_PATH || './resources/templates',
|
||||
templatePath: process.env.TEMPLATE_PATH || './resources/templates/iracing',
|
||||
confidence: parseFloatSafe(process.env.OCR_CONFIDENCE, 0.9),
|
||||
retry: {
|
||||
maxRetries: parseIntSafe(process.env.AUTOMATION_MAX_RETRIES, DEFAULT_RETRY_CONFIG.maxRetries),
|
||||
baseDelayMs: parseIntSafe(process.env.AUTOMATION_BASE_DELAY_MS, DEFAULT_RETRY_CONFIG.baseDelayMs),
|
||||
maxDelayMs: parseIntSafe(process.env.AUTOMATION_MAX_DELAY_MS, DEFAULT_RETRY_CONFIG.maxDelayMs),
|
||||
backoffMultiplier: parseFloatSafe(process.env.AUTOMATION_BACKOFF_MULTIPLIER, DEFAULT_RETRY_CONFIG.backoffMultiplier),
|
||||
},
|
||||
timing: {
|
||||
pageLoadWaitMs: parseIntSafe(process.env.AUTOMATION_PAGE_LOAD_WAIT_MS, DEFAULT_TIMING_CONFIG.pageLoadWaitMs),
|
||||
interActionDelayMs: parseIntSafe(process.env.AUTOMATION_INTER_ACTION_DELAY_MS, DEFAULT_TIMING_CONFIG.interActionDelayMs),
|
||||
postClickDelayMs: parseIntSafe(process.env.AUTOMATION_POST_CLICK_DELAY_MS, DEFAULT_TIMING_CONFIG.postClickDelayMs),
|
||||
preStepDelayMs: parseIntSafe(process.env.AUTOMATION_PRE_STEP_DELAY_MS, DEFAULT_TIMING_CONFIG.preStepDelayMs),
|
||||
},
|
||||
},
|
||||
defaultTimeout: parseIntSafe(process.env.AUTOMATION_TIMEOUT, 30000),
|
||||
retryAttempts: parseIntSafe(process.env.RETRY_ATTEMPTS, 3),
|
||||
@@ -102,7 +167,7 @@ export function loadAutomationConfig(): AutomationEnvironmentConfig {
|
||||
* Type guard to validate automation mode string.
|
||||
*/
|
||||
function isValidAutomationMode(value: string | undefined): value is AutomationMode {
|
||||
return value === 'production' || value === 'test';
|
||||
return value === 'production' || value === 'development' || value === 'test';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user