working companion prototype

This commit is contained in:
2025-11-24 23:32:36 +01:00
parent e7978024d7
commit e2bea9a126
175 changed files with 23227 additions and 3519 deletions

View 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;
}

View File

@@ -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;

View 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();
}
}

View 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();
}
}

View 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();
}
}

View File

@@ -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;

View File

@@ -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 {

View 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];

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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);
}

View 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 };
}

View 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;

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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

View File

@@ -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,
};
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -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 }
),
},
},

View File

@@ -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';
}
/**