refactoring

This commit is contained in:
2025-11-30 02:07:08 +01:00
parent 5c665ea2fe
commit af14526ae2
33 changed files with 4014 additions and 2131 deletions

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,175 @@
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 1-18 map to the corresponding HTML fixture files.
*/
const STEP_TO_FIXTURE: Record<number, string> = {
1: '01-hosted-racing.html',
2: '02-create-a-race.html',
3: '03-race-information.html',
4: '04-server-details.html',
5: '05-set-admins.html',
6: '06-add-an-admin.html',
7: '07-time-limits.html',
8: '08-set-cars.html',
9: '09-add-a-car.html',
10: '10-set-car-classes.html',
11: '11-set-track.html',
12: '12-add-a-track.html',
13: '13-track-options.html',
14: '14-time-of-day.html',
15: '15-weather.html',
16: '16-race-options.html',
17: '17-team-driving.html',
18: '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(), 'html-dumps/iracing-hosted-sessions');
}
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') {
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 || '/';
let fileName: string;
if (urlPath === '/') {
fileName = STEP_TO_FIXTURE[1];
} else {
fileName = urlPath.replace(/^\//, '');
const legacyMatch = fileName.match(/^step-(\d+)-/);
if (legacyMatch) {
const stepNum = Number(legacyMatch[1]);
const mapped = STEP_TO_FIXTURE[stepNum];
if (mapped) {
fileName = mapped;
}
}
}
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) {
const errno = (err as NodeJS.ErrnoException).code;
if (errno === '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,134 @@
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';
export class MockAutomationEngineAdapter 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 (e.g., MockBrowserAutomationAdapter)
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,160 @@
import { StepId } from '../../../domain/value-objects/StepId';
import { HostedSessionConfig } from '../../../domain/entities/HostedSessionConfig';
import type { IBrowserAutomation } from '../../../application/ports/IScreenAutomation';
import {
NavigationResult,
FormFillResult,
ClickResult,
WaitResult,
ModalResult,
AutomationResult,
} from '../../../application/ports/AutomationResults';
interface MockConfig {
simulateFailures?: boolean;
failureRate?: number;
}
interface StepExecutionResult {
success: boolean;
stepId: number;
wasModalStep?: boolean;
shouldStop?: boolean;
executionTime: number;
metrics: {
totalDelay: number;
operationCount: number;
};
}
export class MockBrowserAutomationAdapter implements IBrowserAutomation {
private config: MockConfig;
private connected: boolean = false;
constructor(config: MockConfig = {}) {
this.config = {
simulateFailures: config.simulateFailures ?? false,
failureRate: config.failureRate ?? 0.1,
};
}
async connect(): Promise<AutomationResult> {
this.connected = true;
return { success: true };
}
async disconnect(): Promise<void> {
this.connected = false;
}
isConnected(): boolean {
return this.connected;
}
async navigateToPage(url: string): Promise<NavigationResult> {
const delay = this.randomDelay(200, 800);
await this.sleep(delay);
return {
success: true,
url,
loadTime: delay,
};
}
async fillFormField(fieldName: string, value: string): Promise<FormFillResult> {
const delay = this.randomDelay(100, 500);
await this.sleep(delay);
return {
success: true,
fieldName,
valueSet: value,
};
}
async clickElement(selector: string): Promise<ClickResult> {
const delay = this.randomDelay(50, 300);
await this.sleep(delay);
return {
success: true,
target: selector,
};
}
async waitForElement(selector: string, maxWaitMs: number = 5000): Promise<WaitResult> {
const delay = this.randomDelay(100, 1000);
await this.sleep(delay);
return {
success: true,
target: selector,
waitedMs: delay,
found: true,
};
}
async handleModal(stepId: StepId, action: string): Promise<ModalResult> {
if (!stepId.isModalStep()) {
throw new Error(`Step ${stepId.value} is not a modal step`);
}
const delay = this.randomDelay(200, 600);
await this.sleep(delay);
return {
success: true,
stepId: stepId.value,
action,
};
}
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
if (this.shouldSimulateFailure()) {
throw new Error(`Simulated failure at step ${stepId.value}`);
}
const startTime = Date.now();
let totalDelay = 0;
let operationCount = 0;
const navigationDelay = this.randomDelay(200, 500);
await this.sleep(navigationDelay);
totalDelay += navigationDelay;
operationCount++;
if (stepId.isModalStep()) {
const modalDelay = this.randomDelay(200, 400);
await this.sleep(modalDelay);
totalDelay += modalDelay;
operationCount++;
}
const executionTime = Date.now() - startTime;
return {
success: true,
metadata: {
stepId: stepId.value,
wasModalStep: stepId.isModalStep(),
shouldStop: stepId.isFinalStep(),
executionTime,
totalDelay,
operationCount,
},
};
}
private randomDelay(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
private async sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
private shouldSimulateFailure(): boolean {
if (!this.config.simulateFailures) {
return false;
}
return Math.random() < (this.config.failureRate || 0.1);
}
}

View File

@@ -0,0 +1,890 @@
import { createImageTemplate, DEFAULT_CONFIDENCE, type CategorizedTemplate } from '../../../../domain/value-objects/ImageTemplate';
import type { ImageTemplate } from '../../../../domain/value-objects/ImageTemplate';
/**
* Template definitions for iRacing UI elements.
*
* These templates replace CSS selectors with image-based matching for TOS-compliant
* OS-level automation. Templates reference images in resources/templates/iracing/
*
* Template images should be captured from the actual iRacing UI at standard resolution.
* Recommended: 1920x1080 or 2560x1440 with PNG format for lossless quality.
*/
/**
* Step template configuration containing all templates needed for a workflow step.
*/
export interface StepTemplates {
/** Templates to detect if we're on this step */
indicators: ImageTemplate[];
/** Button templates for navigation and actions */
buttons: Record<string, ImageTemplate>;
/** Field templates for form inputs */
fields?: Record<string, ImageTemplate>;
/** Modal-related templates if applicable */
modal?: {
indicator: ImageTemplate;
closeButton: ImageTemplate;
confirmButton?: ImageTemplate;
searchInput?: ImageTemplate;
};
}
/**
* Complete template map type for iRacing automation.
*/
export interface IRacingTemplateMapType {
/** Common templates used across multiple steps */
common: {
/** Logged-in state indicators */
loginIndicators: ImageTemplate[];
/** Logged-out state indicators */
logoutIndicators: ImageTemplate[];
/** Generic navigation buttons */
navigation: Record<string, ImageTemplate>;
/** Loading indicators */
loading: ImageTemplate[];
};
/** Step-specific templates */
steps: Record<number, StepTemplates>;
/** Base path for template images */
templateBasePath: string;
}
/**
* Template paths for iRacing UI elements.
* All paths are relative to resources/templates/iracing/
*/
const TEMPLATE_PATHS = {
common: {
login: 'common/login-indicator.png',
logout: 'common/logout-indicator.png',
userAvatar: 'common/user-avatar.png',
memberBadge: 'common/member-badge.png',
loginButton: 'common/login-button.png',
loadingSpinner: 'common/loading-spinner.png',
nextButton: 'common/next-button.png',
backButton: 'common/back-button.png',
checkoutButton: 'common/checkout-button.png',
closeModal: 'common/close-modal-button.png',
},
steps: {
1: {
loginForm: 'step01-login/login-form.png',
emailField: 'step01-login/email-field.png',
passwordField: 'step01-login/password-field.png',
submitButton: 'step01-login/submit-button.png',
},
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',
},
3: {
createRaceModal: 'step03-create/create-race-modal.png',
confirmButton: 'step03-create/confirm-button.png',
},
4: {
stepIndicator: 'step04-info/race-info-indicator.png',
sessionNameField: 'step04-info/session-name-field.png',
passwordField: 'step04-info/password-field.png',
descriptionField: 'step04-info/description-field.png',
nextButton: 'step04-info/next-button.png',
},
5: {
stepIndicator: 'step05-server/server-details-indicator.png',
regionDropdown: 'step05-server/region-dropdown.png',
startNowToggle: 'step05-server/start-now-toggle.png',
nextButton: 'step05-server/next-button.png',
},
6: {
stepIndicator: 'step06-admins/admins-indicator.png',
addAdminButton: 'step06-admins/add-admin-button.png',
adminModal: 'step06-admins/admin-modal.png',
searchField: 'step06-admins/search-field.png',
nextButton: 'step06-admins/next-button.png',
},
7: {
stepIndicator: 'step07-time/time-limits-indicator.png',
practiceField: 'step07-time/practice-field.png',
qualifyField: 'step07-time/qualify-field.png',
raceField: 'step07-time/race-field.png',
nextButton: 'step07-time/next-button.png',
},
8: {
stepIndicator: 'step08-cars/cars-indicator.png',
addCarButton: 'step08-cars/add-car-button.png',
carList: 'step08-cars/car-list.png',
nextButton: 'step08-cars/next-button.png',
},
9: {
carModal: 'step09-addcar/car-modal.png',
searchField: 'step09-addcar/search-field.png',
carGrid: 'step09-addcar/car-grid.png',
selectButton: 'step09-addcar/select-button.png',
closeButton: 'step09-addcar/close-button.png',
},
10: {
stepIndicator: 'step10-classes/car-classes-indicator.png',
classDropdown: 'step10-classes/class-dropdown.png',
nextButton: 'step10-classes/next-button.png',
},
11: {
stepIndicator: 'step11-track/track-indicator.png',
addTrackButton: 'step11-track/add-track-button.png',
trackList: 'step11-track/track-list.png',
nextButton: 'step11-track/next-button.png',
},
12: {
trackModal: 'step12-addtrack/track-modal.png',
searchField: 'step12-addtrack/search-field.png',
trackGrid: 'step12-addtrack/track-grid.png',
selectButton: 'step12-addtrack/select-button.png',
closeButton: 'step12-addtrack/close-button.png',
},
13: {
stepIndicator: 'step13-trackopts/track-options-indicator.png',
configDropdown: 'step13-trackopts/config-dropdown.png',
nextButton: 'step13-trackopts/next-button.png',
},
14: {
stepIndicator: 'step14-tod/time-of-day-indicator.png',
timeSlider: 'step14-tod/time-slider.png',
datePicker: 'step14-tod/date-picker.png',
nextButton: 'step14-tod/next-button.png',
},
15: {
stepIndicator: 'step15-weather/weather-indicator.png',
weatherDropdown: 'step15-weather/weather-dropdown.png',
temperatureField: 'step15-weather/temperature-field.png',
nextButton: 'step15-weather/next-button.png',
},
16: {
stepIndicator: 'step16-race/race-options-indicator.png',
maxDriversField: 'step16-race/max-drivers-field.png',
rollingStartToggle: 'step16-race/rolling-start-toggle.png',
nextButton: 'step16-race/next-button.png',
},
17: {
stepIndicator: 'step17-team/team-driving-indicator.png',
teamDrivingToggle: 'step17-team/team-driving-toggle.png',
nextButton: 'step17-team/next-button.png',
},
18: {
stepIndicator: 'step18-conditions/track-conditions-indicator.png',
trackStateDropdown: 'step18-conditions/track-state-dropdown.png',
marblesToggle: 'step18-conditions/marbles-toggle.png',
// NOTE: No checkout button template - automation stops here for safety
},
},
} as const;
/**
* Complete template map for iRacing hosted session automation.
* Templates are organized by common elements and workflow steps.
*/
export const IRacingTemplateMap: IRacingTemplateMapType = {
templateBasePath: 'resources/templates/iracing',
common: {
loginIndicators: [
createImageTemplate(
'login-user-avatar',
TEMPLATE_PATHS.common.userAvatar,
'User avatar indicating logged-in state',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
createImageTemplate(
'login-member-badge',
TEMPLATE_PATHS.common.memberBadge,
'Member badge indicating logged-in state',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
logoutIndicators: [
createImageTemplate(
'logout-login-button',
TEMPLATE_PATHS.common.loginButton,
'Login button indicating logged-out state',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
],
navigation: {
next: createImageTemplate(
'nav-next',
TEMPLATE_PATHS.common.nextButton,
'Next button for wizard navigation',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
back: createImageTemplate(
'nav-back',
TEMPLATE_PATHS.common.backButton,
'Back button for wizard navigation',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
checkout: createImageTemplate(
'nav-checkout',
TEMPLATE_PATHS.common.checkoutButton,
'Checkout/submit button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
closeModal: createImageTemplate(
'nav-close-modal',
TEMPLATE_PATHS.common.closeModal,
'Close modal button',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
},
loading: [
createImageTemplate(
'loading-spinner',
TEMPLATE_PATHS.common.loadingSpinner,
'Loading spinner indicator',
{ confidence: DEFAULT_CONFIDENCE.LOW }
),
],
},
steps: {
// Step 1: LOGIN (handled externally, templates for detection only)
1: {
indicators: [
createImageTemplate(
'step1-login-form',
TEMPLATE_PATHS.steps[1].loginForm,
'Login form indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
submit: createImageTemplate(
'step1-submit',
TEMPLATE_PATHS.steps[1].submitButton,
'Login submit button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
},
fields: {
email: createImageTemplate(
'step1-email',
TEMPLATE_PATHS.steps[1].emailField,
'Email input field',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
password: createImageTemplate(
'step1-password',
TEMPLATE_PATHS.steps[1].passwordField,
'Password input field',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
},
},
// 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.DEBUG }
),
],
buttons: {
createRace: createImageTemplate(
'step2-create-race',
TEMPLATE_PATHS.steps[2].createRaceButton,
'Create a Race button',
{ confidence: DEFAULT_CONFIDENCE.DEBUG }
),
},
},
// Step 3: CREATE_RACE
3: {
indicators: [
createImageTemplate(
'step3-modal',
TEMPLATE_PATHS.steps[3].createRaceModal,
'Create race modal indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
confirm: createImageTemplate(
'step3-confirm',
TEMPLATE_PATHS.steps[3].confirmButton,
'Confirm create race button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
},
},
// Step 4: RACE_INFORMATION
4: {
indicators: [
createImageTemplate(
'step4-indicator',
TEMPLATE_PATHS.steps[4].stepIndicator,
'Race information step indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
next: createImageTemplate(
'step4-next',
TEMPLATE_PATHS.steps[4].nextButton,
'Next to Server Details button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
},
fields: {
sessionName: createImageTemplate(
'step4-session-name',
TEMPLATE_PATHS.steps[4].sessionNameField,
'Session name input field',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
password: createImageTemplate(
'step4-password',
TEMPLATE_PATHS.steps[4].passwordField,
'Session password input field',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
description: createImageTemplate(
'step4-description',
TEMPLATE_PATHS.steps[4].descriptionField,
'Session description textarea',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
},
},
// Step 5: SERVER_DETAILS
5: {
indicators: [
createImageTemplate(
'step5-indicator',
TEMPLATE_PATHS.steps[5].stepIndicator,
'Server details step indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
next: createImageTemplate(
'step5-next',
TEMPLATE_PATHS.steps[5].nextButton,
'Next to Admins button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
},
fields: {
region: createImageTemplate(
'step5-region',
TEMPLATE_PATHS.steps[5].regionDropdown,
'Server region dropdown',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
startNow: createImageTemplate(
'step5-start-now',
TEMPLATE_PATHS.steps[5].startNowToggle,
'Start now toggle',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
},
},
// Step 6: SET_ADMINS (modal step)
6: {
indicators: [
createImageTemplate(
'step6-indicator',
TEMPLATE_PATHS.steps[6].stepIndicator,
'Admins step indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
addAdmin: createImageTemplate(
'step6-add-admin',
TEMPLATE_PATHS.steps[6].addAdminButton,
'Add admin button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
next: createImageTemplate(
'step6-next',
TEMPLATE_PATHS.steps[6].nextButton,
'Next to Time Limits button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
},
modal: {
indicator: createImageTemplate(
'step6-modal',
TEMPLATE_PATHS.steps[6].adminModal,
'Add admin modal indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
closeButton: createImageTemplate(
'step6-modal-close',
TEMPLATE_PATHS.common.closeModal,
'Close admin modal button',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
searchInput: createImageTemplate(
'step6-search',
TEMPLATE_PATHS.steps[6].searchField,
'Admin search field',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
},
},
// Step 7: TIME_LIMITS
7: {
indicators: [
createImageTemplate(
'step7-indicator',
TEMPLATE_PATHS.steps[7].stepIndicator,
'Time limits step indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
next: createImageTemplate(
'step7-next',
TEMPLATE_PATHS.steps[7].nextButton,
'Next to Cars button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
},
fields: {
practice: createImageTemplate(
'step7-practice',
TEMPLATE_PATHS.steps[7].practiceField,
'Practice length field',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
qualify: createImageTemplate(
'step7-qualify',
TEMPLATE_PATHS.steps[7].qualifyField,
'Qualify length field',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
race: createImageTemplate(
'step7-race',
TEMPLATE_PATHS.steps[7].raceField,
'Race length field',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
},
},
// Step 8: SET_CARS
8: {
indicators: [
createImageTemplate(
'step8-indicator',
TEMPLATE_PATHS.steps[8].stepIndicator,
'Cars step indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
addCar: createImageTemplate(
'step8-add-car',
TEMPLATE_PATHS.steps[8].addCarButton,
'Add car button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
next: createImageTemplate(
'step8-next',
TEMPLATE_PATHS.steps[8].nextButton,
'Next to Track button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
},
},
// Step 9: ADD_CAR (modal step)
9: {
indicators: [
createImageTemplate(
'step9-modal',
TEMPLATE_PATHS.steps[9].carModal,
'Add car modal indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
select: createImageTemplate(
'step9-select',
TEMPLATE_PATHS.steps[9].selectButton,
'Select car button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
},
modal: {
indicator: createImageTemplate(
'step9-modal-indicator',
TEMPLATE_PATHS.steps[9].carModal,
'Car selection modal',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
closeButton: createImageTemplate(
'step9-close',
TEMPLATE_PATHS.steps[9].closeButton,
'Close car modal button',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
searchInput: createImageTemplate(
'step9-search',
TEMPLATE_PATHS.steps[9].searchField,
'Car search field',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
},
},
// Step 10: SET_CAR_CLASSES
10: {
indicators: [
createImageTemplate(
'step10-indicator',
TEMPLATE_PATHS.steps[10].stepIndicator,
'Car classes step indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
next: createImageTemplate(
'step10-next',
TEMPLATE_PATHS.steps[10].nextButton,
'Next to Track button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
},
fields: {
class: createImageTemplate(
'step10-class',
TEMPLATE_PATHS.steps[10].classDropdown,
'Car class dropdown',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
},
},
// Step 11: SET_TRACK
11: {
indicators: [
createImageTemplate(
'step11-indicator',
TEMPLATE_PATHS.steps[11].stepIndicator,
'Track step indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
addTrack: createImageTemplate(
'step11-add-track',
TEMPLATE_PATHS.steps[11].addTrackButton,
'Add track button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
next: createImageTemplate(
'step11-next',
TEMPLATE_PATHS.steps[11].nextButton,
'Next to Track Options button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
},
},
// Step 12: ADD_TRACK (modal step)
12: {
indicators: [
createImageTemplate(
'step12-modal',
TEMPLATE_PATHS.steps[12].trackModal,
'Add track modal indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
select: createImageTemplate(
'step12-select',
TEMPLATE_PATHS.steps[12].selectButton,
'Select track button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
},
modal: {
indicator: createImageTemplate(
'step12-modal-indicator',
TEMPLATE_PATHS.steps[12].trackModal,
'Track selection modal',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
closeButton: createImageTemplate(
'step12-close',
TEMPLATE_PATHS.steps[12].closeButton,
'Close track modal button',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
searchInput: createImageTemplate(
'step12-search',
TEMPLATE_PATHS.steps[12].searchField,
'Track search field',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
},
},
// Step 13: TRACK_OPTIONS
13: {
indicators: [
createImageTemplate(
'step13-indicator',
TEMPLATE_PATHS.steps[13].stepIndicator,
'Track options step indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
next: createImageTemplate(
'step13-next',
TEMPLATE_PATHS.steps[13].nextButton,
'Next to Time of Day button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
},
fields: {
config: createImageTemplate(
'step13-config',
TEMPLATE_PATHS.steps[13].configDropdown,
'Track configuration dropdown',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
},
},
// Step 14: TIME_OF_DAY
14: {
indicators: [
createImageTemplate(
'step14-indicator',
TEMPLATE_PATHS.steps[14].stepIndicator,
'Time of day step indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
next: createImageTemplate(
'step14-next',
TEMPLATE_PATHS.steps[14].nextButton,
'Next to Weather button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
},
fields: {
time: createImageTemplate(
'step14-time',
TEMPLATE_PATHS.steps[14].timeSlider,
'Time of day slider',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
date: createImageTemplate(
'step14-date',
TEMPLATE_PATHS.steps[14].datePicker,
'Date picker',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
},
},
// Step 15: WEATHER
15: {
indicators: [
createImageTemplate(
'step15-indicator',
TEMPLATE_PATHS.steps[15].stepIndicator,
'Weather step indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
next: createImageTemplate(
'step15-next',
TEMPLATE_PATHS.steps[15].nextButton,
'Next to Race Options button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
},
fields: {
weather: createImageTemplate(
'step15-weather',
TEMPLATE_PATHS.steps[15].weatherDropdown,
'Weather type dropdown',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
temperature: createImageTemplate(
'step15-temperature',
TEMPLATE_PATHS.steps[15].temperatureField,
'Temperature field',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
},
},
// Step 16: RACE_OPTIONS
16: {
indicators: [
createImageTemplate(
'step16-indicator',
TEMPLATE_PATHS.steps[16].stepIndicator,
'Race options step indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
next: createImageTemplate(
'step16-next',
TEMPLATE_PATHS.steps[16].nextButton,
'Next to Track Conditions button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
},
fields: {
maxDrivers: createImageTemplate(
'step16-max-drivers',
TEMPLATE_PATHS.steps[16].maxDriversField,
'Maximum drivers field',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
rollingStart: createImageTemplate(
'step16-rolling-start',
TEMPLATE_PATHS.steps[16].rollingStartToggle,
'Rolling start toggle',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
},
},
// Step 17: TEAM_DRIVING
17: {
indicators: [
createImageTemplate(
'step17-indicator',
TEMPLATE_PATHS.steps[17].stepIndicator,
'Team driving step indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
next: createImageTemplate(
'step17-next',
TEMPLATE_PATHS.steps[17].nextButton,
'Next to Track Conditions button',
{ confidence: DEFAULT_CONFIDENCE.HIGH }
),
},
fields: {
teamDriving: createImageTemplate(
'step17-team-driving',
TEMPLATE_PATHS.steps[17].teamDrivingToggle,
'Team driving toggle',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
},
},
// Step 18: TRACK_CONDITIONS (final step - no checkout for safety)
18: {
indicators: [
createImageTemplate(
'step18-indicator',
TEMPLATE_PATHS.steps[18].stepIndicator,
'Track conditions step indicator',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
],
buttons: {
// NOTE: No checkout button - automation intentionally stops here
// User must manually review and submit
},
fields: {
trackState: createImageTemplate(
'step18-track-state',
TEMPLATE_PATHS.steps[18].trackStateDropdown,
'Track state dropdown',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
marbles: createImageTemplate(
'step18-marbles',
TEMPLATE_PATHS.steps[18].marblesToggle,
'Marbles toggle',
{ confidence: DEFAULT_CONFIDENCE.STANDARD }
),
},
},
},
};
/**
* Get templates for a specific step.
*/
export function getStepTemplates(stepId: number): StepTemplates | undefined {
return IRacingTemplateMap.steps[stepId];
}
/**
* Check if a step is a modal step (requires opening a secondary dialog).
*/
export function isModalStep(stepId: number): boolean {
const templates = IRacingTemplateMap.steps[stepId];
return templates?.modal !== undefined;
}
/**
* Get the step name for logging/debugging.
*/
export function getStepName(stepId: number): string {
const stepNames: Record<number, string> = {
1: 'LOGIN',
2: 'HOSTED_RACING',
3: 'CREATE_RACE',
4: 'RACE_INFORMATION',
5: 'SERVER_DETAILS',
6: 'SET_ADMINS',
7: 'TIME_LIMITS',
8: 'SET_CARS',
9: 'ADD_CAR',
10: 'SET_CAR_CLASSES',
11: 'SET_TRACK',
12: 'ADD_TRACK',
13: 'TRACK_OPTIONS',
14: 'TIME_OF_DAY',
15: 'WEATHER',
16: 'RACE_OPTIONS',
17: 'TEAM_DRIVING',
18: 'TRACK_CONDITIONS',
};
return stepNames[stepId] || `UNKNOWN_STEP_${stepId}`;
}
/**
* Get all login indicator templates.
*/
export function getLoginIndicators(): ImageTemplate[] {
return IRacingTemplateMap.common.loginIndicators;
}
/**
* Get all logout indicator templates.
*/
export function getLogoutIndicators(): ImageTemplate[] {
return IRacingTemplateMap.common.logoutIndicators;
}