refactor(automation): remove browser automation, use OS-level automation only

This commit is contained in:
2025-11-22 17:57:35 +01:00
parent 99fa06e12b
commit 84800c663a
44 changed files with 110 additions and 5125 deletions

View File

@@ -1,166 +0,0 @@
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
export interface IFixtureServerService {
start(port: number, fixturesPath: string): Promise<void>;
stop(): Promise<void>;
waitForReady(timeoutMs: number): Promise<boolean>;
getBaseUrl(): string;
isRunning(): boolean;
}
export class FixtureServerService implements IFixtureServerService {
private server: http.Server | null = null;
private port: number = 3456;
private resolvedFixturesPath: string = '';
async start(port: number, fixturesPath: string): Promise<void> {
if (this.server) {
throw new Error('Fixture server is already running');
}
this.port = port;
this.resolvedFixturesPath = path.resolve(fixturesPath);
if (!fs.existsSync(this.resolvedFixturesPath)) {
throw new Error(`Fixtures path does not exist: ${this.resolvedFixturesPath}`);
}
return new Promise((resolve, reject) => {
this.server = http.createServer((req, res) => {
this.handleRequest(req, res);
});
this.server.on('error', (error) => {
this.server = null;
reject(error);
});
this.server.listen(this.port, () => {
resolve();
});
});
}
async stop(): Promise<void> {
if (!this.server) {
return;
}
return new Promise((resolve, reject) => {
this.server!.close((error) => {
this.server = null;
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
async waitForReady(timeoutMs: number = 5000): Promise<boolean> {
const startTime = Date.now();
const pollInterval = 100;
while (Date.now() - startTime < timeoutMs) {
const isReady = await this.checkHealth();
if (isReady) {
return true;
}
await this.sleep(pollInterval);
}
return false;
}
getBaseUrl(): string {
return `http://localhost:${this.port}`;
}
isRunning(): boolean {
return this.server !== null;
}
private async checkHealth(): Promise<boolean> {
return new Promise((resolve) => {
const req = http.get(`${this.getBaseUrl()}/health`, (res) => {
resolve(res.statusCode === 200);
});
req.on('error', () => {
resolve(false);
});
req.setTimeout(1000, () => {
req.destroy();
resolve(false);
});
});
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
const url = req.url || '/';
res.setHeader('Access-Control-Allow-Origin', '*');
if (url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return;
}
const sanitizedUrl = url.startsWith('/') ? url.slice(1) : url;
const filePath = path.join(this.resolvedFixturesPath, sanitizedUrl || 'index.html');
const normalizedFilePath = path.normalize(filePath);
if (!normalizedFilePath.startsWith(this.resolvedFixturesPath)) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Forbidden');
return;
}
fs.stat(normalizedFilePath, (statErr, stats) => {
if (statErr || !stats.isFile()) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
return;
}
const ext = path.extname(normalizedFilePath).toLowerCase();
const contentType = this.getContentType(ext);
fs.readFile(normalizedFilePath, (readErr, data) => {
if (readErr) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
return;
}
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
});
});
}
private getContentType(ext: string): string {
const mimeTypes: Record<string, string> = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
};
return mimeTypes[ext] || 'application/octet-stream';
}
}

View File

@@ -1,9 +1,9 @@
import { IAutomationEngine, ValidationResult } from '../../../packages/application/ports/IAutomationEngine';
import { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
import { StepId } from '../../../packages/domain/value-objects/StepId';
import { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation';
import { ISessionRepository } from '../../../packages/application/ports/ISessionRepository';
import { getStepName } from './selectors/IRacingSelectorMap';
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 automationInterval: NodeJS.Timeout | null = null;
@@ -62,7 +62,7 @@ export class MockAutomationEngineAdapter implements IAutomationEngine {
// Execute current step using the browser automation
if (this.browserAutomation.executeStep) {
// Use real workflow automation with IRacingSelectorMap
const result = await this.browserAutomation.executeStep(currentStep, config as Record<string, unknown>);
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);

View File

@@ -1,13 +1,14 @@
import { StepId } from '../../../packages/domain/value-objects/StepId';
import { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
import { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation';
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,
} from '../../../packages/application/ports/AutomationResults';
AutomationResult,
} from '../../../application/ports/AutomationResults';
interface MockConfig {
simulateFailures?: boolean;
@@ -37,8 +38,9 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation {
};
}
async connect(): Promise<void> {
async connect(): Promise<AutomationResult> {
this.connected = true;
return { success: true };
}
async disconnect(): Promise<void> {
@@ -105,7 +107,7 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation {
};
}
async executeStep(stepId: StepId, config: HostedSessionConfig): Promise<StepExecutionResult> {
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
if (this.shouldSimulateFailure()) {
throw new Error(`Simulated failure at step ${stepId.value}`);
}
@@ -130,11 +132,11 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation {
return {
success: true,
stepId: stepId.value,
wasModalStep: stepId.isModalStep(),
shouldStop: stepId.isFinalStep(),
executionTime,
metrics: {
metadata: {
stepId: stepId.value,
wasModalStep: stepId.isModalStep(),
shouldStop: stepId.isFinalStep(),
executionTime,
totalDelay,
operationCount,
},

View File

@@ -94,7 +94,11 @@ export class PermissionService {
platform,
};
this.logger.debug('Permission status retrieved', this.cachedStatus);
this.logger.debug('Permission status retrieved', {
accessibility: this.cachedStatus.accessibility,
screenRecording: this.cachedStatus.screenRecording,
platform: this.cachedStatus.platform,
});
return this.cachedStatus;
}

View File

@@ -71,7 +71,7 @@ export class TemplateMatchingService {
template.searchRegion.width,
template.searchRegion.height
);
foundRegion = await screen.findRegion(templateImage, searchArea);
foundRegion = await screen.find(templateImage, { searchRegion: searchArea });
} else {
// Search entire screen
foundRegion = await screen.find(templateImage);

View File

@@ -1,31 +1,35 @@
/**
* Automation adapters for browser automation.
* Automation adapters for OS-level screen automation.
*
* Exports:
* - MockBrowserAutomationAdapter: Mock adapter for testing
* - BrowserDevToolsAdapter: Real browser automation via Chrome DevTools Protocol
* - NutJsAutomationAdapter: OS-level automation via nut.js
* - PermissionService: macOS permission checking for automation
* - IRacingSelectorMap: CSS selectors for iRacing UI elements
* - 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
*/
// Adapters
export { MockBrowserAutomationAdapter } from './MockBrowserAutomationAdapter';
export { BrowserDevToolsAdapter, DevToolsConfig } from './BrowserDevToolsAdapter';
export { NutJsAutomationAdapter, NutJsConfig } from './NutJsAutomationAdapter';
export { NutJsAutomationAdapter } from './NutJsAutomationAdapter';
export type { NutJsConfig } from './NutJsAutomationAdapter';
// Permission service
export { PermissionService, PermissionStatus, PermissionCheckResult } from './PermissionService';
// Services
export { PermissionService } from './PermissionService';
export type { PermissionStatus, PermissionCheckResult } from './PermissionService';
export { ScreenRecognitionService } from './ScreenRecognitionService';
export { TemplateMatchingService } from './TemplateMatchingService';
export { WindowFocusService } from './WindowFocusService';
// Fixture server
export { FixtureServerService, IFixtureServerService } from './FixtureServerService';
// Selector map and utilities
// Template map and utilities
export {
IRacingSelectorMap,
IRacingSelectorMapType,
StepSelectors,
getStepSelectors,
IRacingTemplateMap,
getStepTemplates,
getStepName,
isModalStep,
} from './selectors/IRacingSelectorMap';
getLoginIndicators,
getLogoutIndicators,
} from './templates/IRacingTemplateMap';
export type { IRacingTemplateMapType, StepTemplates } from './templates/IRacingTemplateMap';

View File

@@ -1,399 +0,0 @@
/**
* CSS Selector map for iRacing hosted session workflow.
* Selectors are derived from HTML samples in resources/iracing-hosted-sessions/
*
* The iRacing UI uses Chakra UI/React with dynamic CSS classes.
* We prefer stable selectors: data-testid, id, aria-labels, role attributes.
*/
export interface StepSelectors {
/** Primary container/step identifier */
container?: string;
/** Wizard sidebar navigation link */
sidebarLink?: string;
/** Wizard top navigation link */
wizardNav?: string;
/** Form fields for this step */
fields?: Record<string, string>;
/** Buttons specific to this step */
buttons?: Record<string, string>;
/** Modal selectors if this is a modal step */
modal?: {
container: string;
closeButton: string;
confirmButton?: string;
searchInput?: string;
resultsList?: string;
selectButton?: string;
};
}
export interface IRacingSelectorMapType {
/** Common selectors used across multiple steps */
common: {
mainModal: string;
modalDialog: string;
modalContent: string;
modalTitle: string;
modalCloseButton: string;
checkoutButton: string;
backButton: string;
nextButton: string;
wizardContainer: string;
wizardSidebar: string;
searchInput: string;
loadingSpinner: string;
};
/** Step-specific selectors */
steps: Record<number, StepSelectors>;
/** iRacing-specific URLs */
urls: {
base: string;
hostedRacing: string;
login: string;
};
}
/**
* Complete selector map for iRacing hosted session creation workflow.
*
* Steps:
* 1. LOGIN - Login page (handled externally)
* 2. HOSTED_RACING - Navigate to hosted racing section
* 3. CREATE_RACE - Click create race button
* 4. RACE_INFORMATION - Fill session name, password, description
* 5. SERVER_DETAILS - Select server region, launch time
* 6. SET_ADMINS - Admin configuration (modal at step 6)
* 7. TIME_LIMITS - Configure time limits
* 8. SET_CARS - Car selection overview
* 9. ADD_CAR - Add a car (modal at step 9)
* 10. SET_CAR_CLASSES - Configure car classes
* 11. SET_TRACK - Track selection overview
* 12. ADD_TRACK - Add a track (modal at step 12)
* 13. TRACK_OPTIONS - Configure track options
* 14. TIME_OF_DAY - Configure time of day
* 15. WEATHER - Configure weather
* 16. RACE_OPTIONS - Configure race options
* 17. TEAM_DRIVING - Configure team driving
* 18. TRACK_CONDITIONS - Final review (safety checkpoint - no final submit)
*/
export const IRacingSelectorMap: IRacingSelectorMapType = {
common: {
mainModal: '#create-race-modal',
modalDialog: '#create-race-modal-modal-dialog',
modalContent: '#create-race-modal-modal-content',
modalTitle: '[data-testid="modal-title"]',
modalCloseButton: '.modal-header .close, [data-testid="button-close-modal"]',
checkoutButton: '.btn.btn-success',
backButton: '.btn.btn-secondary:has(.icon-caret-left)',
nextButton: '.btn.btn-secondary:has(.icon-caret-right)',
wizardContainer: '#create-race-wizard',
wizardSidebar: '.wizard-sidebar',
searchInput: '.wizard-sidebar input[type="text"][placeholder="Search"]',
loadingSpinner: '.loader-container .loader',
},
urls: {
base: 'https://members-ng.iracing.com',
hostedRacing: 'https://members-ng.iracing.com/web/racing/hosted',
login: 'https://members-ng.iracing.com/login',
},
steps: {
// Step 1: LOGIN - External, handled before automation
1: {
container: '#login-form, .login-container',
fields: {
email: 'input[name="email"], #email',
password: 'input[name="password"], #password',
},
buttons: {
submit: 'button[type="submit"], .login-button',
},
},
// Step 2: HOSTED_RACING - Navigate to hosted racing page
2: {
container: '#hosted-sessions, [data-page="hosted"]',
sidebarLink: 'a[href*="/racing/hosted"]',
buttons: {
createRace: '.btn:has-text("Create a Race"), [data-action="create-race"]',
},
},
// Step 3: CREATE_RACE - Click create race to open modal
3: {
container: '[data-modal-component="ModalCreateRace"]',
buttons: {
createRace: 'button:has-text("Create a Race"), .btn-primary:has-text("Create")',
},
},
// Step 4: RACE_INFORMATION - Fill session name, password, description
4: {
container: '#set-session-information',
sidebarLink: '#wizard-sidebar-link-set-session-information',
wizardNav: '[data-testid="wizard-nav-set-session-information"]',
fields: {
sessionName: '.form-group:has(label:has-text("Session Name")) input, input[name="sessionName"]',
password: '.form-group:has(label:has-text("Password")) input, input[name="password"]',
description: '.form-group:has(label:has-text("Description")) textarea, textarea[name="description"]',
},
buttons: {
next: '.wizard-footer .btn:has-text("Server Details")',
},
},
// Step 5: SERVER_DETAILS - Select server region and launch time
5: {
container: '#set-server-details',
sidebarLink: '#wizard-sidebar-link-set-server-details',
wizardNav: '[data-testid="wizard-nav-set-server-details"]',
fields: {
serverRegion: '.chakra-accordion__button[data-index="0"]',
launchTime: 'input[name="launchTime"], [id*="field-"]:has(+ [placeholder="Now"])',
startNow: '.switch:has(input[value="startNow"])',
},
buttons: {
next: '.wizard-footer .btn:has-text("Admins")',
back: '.wizard-footer .btn:has-text("Race Information")',
},
},
// Step 6: SET_ADMINS - Admin configuration (modal step)
6: {
container: '#set-admins',
sidebarLink: '#wizard-sidebar-link-set-admins',
wizardNav: '[data-testid="wizard-nav-set-admins"]',
buttons: {
addAdmin: '.btn:has-text("Add Admin"), .btn-primary:has(.icon-add)',
next: '.wizard-footer .btn:has-text("Time Limit")',
back: '.wizard-footer .btn:has-text("Server Details")',
},
modal: {
container: '#add-admin-modal, .modal:has([data-modal-component="AddAdmin"])',
closeButton: '.modal .close, [data-testid="button-close-modal"]',
searchInput: 'input[placeholder*="Search"], input[name="adminSearch"]',
resultsList: '.admin-list, .search-results',
selectButton: '.btn:has-text("Select"), .btn-primary:has-text("Add")',
},
},
// Step 7: TIME_LIMITS - Configure time limits
7: {
container: '#set-time-limit',
sidebarLink: '#wizard-sidebar-link-set-time-limit',
wizardNav: '[data-testid="wizard-nav-set-time-limit"]',
fields: {
practiceLength: 'input[name="practiceLength"]',
qualifyLength: 'input[name="qualifyLength"]',
raceLength: 'input[name="raceLength"]',
warmupLength: 'input[name="warmupLength"]',
},
buttons: {
next: '.wizard-footer .btn:has-text("Cars")',
back: '.wizard-footer .btn:has-text("Admins")',
},
},
// Step 8: SET_CARS - Car selection overview
8: {
container: '#set-cars',
sidebarLink: '#wizard-sidebar-link-set-cars',
wizardNav: '[data-testid="wizard-nav-set-cars"]',
buttons: {
addCar: '.btn:has-text("Add Car"), .btn-primary:has(.icon-add)',
next: '.wizard-footer .btn:has-text("Track")',
back: '.wizard-footer .btn:has-text("Time Limit")',
},
},
// Step 9: ADD_CAR - Add a car (modal step)
9: {
container: '#set-cars',
sidebarLink: '#wizard-sidebar-link-set-cars',
wizardNav: '[data-testid="wizard-nav-set-cars"]',
modal: {
container: '#add-car-modal, .modal:has(.car-list)',
closeButton: '.modal .close, [aria-label="Close"]',
searchInput: 'input[placeholder*="Search"], .car-search input',
resultsList: '.car-list table tbody, .car-grid',
selectButton: '.btn:has-text("Select"), .btn-primary.btn-xs:has-text("Select")',
},
},
// Step 10: SET_CAR_CLASSES - Configure car classes
10: {
container: '#set-car-classes, #set-cars',
sidebarLink: '#wizard-sidebar-link-set-cars',
wizardNav: '[data-testid="wizard-nav-set-cars"]',
fields: {
carClass: 'select[name="carClass"], .car-class-select',
},
buttons: {
next: '.wizard-footer .btn:has-text("Track")',
},
},
// Step 11: SET_TRACK - Track selection overview
11: {
container: '#set-track',
sidebarLink: '#wizard-sidebar-link-set-track',
wizardNav: '[data-testid="wizard-nav-set-track"]',
buttons: {
addTrack: '.btn:has-text("Add Track"), .btn-primary:has(.icon-add)',
next: '.wizard-footer .btn:has-text("Track Options")',
back: '.wizard-footer .btn:has-text("Cars")',
},
},
// Step 12: ADD_TRACK - Add a track (modal step)
12: {
container: '#set-track',
sidebarLink: '#wizard-sidebar-link-set-track',
wizardNav: '[data-testid="wizard-nav-set-track"]',
modal: {
container: '#add-track-modal, .modal:has(.track-list)',
closeButton: '.modal .close, [aria-label="Close"]',
searchInput: 'input[placeholder*="Search"], .track-search input',
resultsList: '.track-list table tbody, .track-grid',
selectButton: '.btn:has-text("Select"), .btn-primary.btn-xs:has-text("Select")',
},
},
// Step 13: TRACK_OPTIONS - Configure track options
13: {
container: '#set-track-options',
sidebarLink: '#wizard-sidebar-link-set-track-options',
wizardNav: '[data-testid="wizard-nav-set-track-options"]',
fields: {
trackConfig: 'select[name="trackConfig"]',
pitStalls: 'input[name="pitStalls"]',
},
buttons: {
next: '.wizard-footer .btn:has-text("Time of Day")',
back: '.wizard-footer .btn:has-text("Track")',
},
},
// Step 14: TIME_OF_DAY - Configure time of day
14: {
container: '#set-time-of-day',
sidebarLink: '#wizard-sidebar-link-set-time-of-day',
wizardNav: '[data-testid="wizard-nav-set-time-of-day"]',
fields: {
timeOfDay: 'input[name="timeOfDay"], .time-slider',
date: 'input[name="date"], .date-picker',
},
buttons: {
next: '.wizard-footer .btn:has-text("Weather")',
back: '.wizard-footer .btn:has-text("Track Options")',
},
},
// Step 15: WEATHER - Configure weather
15: {
container: '#set-weather',
sidebarLink: '#wizard-sidebar-link-set-weather',
wizardNav: '[data-testid="wizard-nav-set-weather"]',
fields: {
weatherType: 'select[name="weatherType"]',
temperature: 'input[name="temperature"]',
humidity: 'input[name="humidity"]',
windSpeed: 'input[name="windSpeed"]',
windDirection: 'input[name="windDirection"]',
},
buttons: {
next: '.wizard-footer .btn:has-text("Race Options")',
back: '.wizard-footer .btn:has-text("Time of Day")',
},
},
// Step 16: RACE_OPTIONS - Configure race options
16: {
container: '#set-race-options',
sidebarLink: '#wizard-sidebar-link-set-race-options',
wizardNav: '[data-testid="wizard-nav-set-race-options"]',
fields: {
maxDrivers: 'input[name="maxDrivers"]',
hardcoreIncidents: '.switch:has(input[name="hardcoreIncidents"])',
rollingStarts: '.switch:has(input[name="rollingStarts"])',
fullCourseCautions: '.switch:has(input[name="fullCourseCautions"])',
},
buttons: {
next: '.wizard-footer .btn:has-text("Track Conditions")',
back: '.wizard-footer .btn:has-text("Weather")',
},
},
// Step 17: TEAM_DRIVING - Configure team driving (if applicable)
17: {
container: '#set-team-driving',
fields: {
teamDriving: '.switch:has(input[name="teamDriving"])',
minDrivers: 'input[name="minDrivers"]',
maxDrivers: 'input[name="maxDrivers"]',
},
buttons: {
next: '.wizard-footer .btn:has-text("Track Conditions")',
back: '.wizard-footer .btn:has-text("Race Options")',
},
},
// Step 18: TRACK_CONDITIONS - Final review (safety checkpoint - NO final submit)
18: {
container: '#set-track-conditions',
sidebarLink: '#wizard-sidebar-link-set-track-conditions',
wizardNav: '[data-testid="wizard-nav-set-track-conditions"]',
fields: {
trackState: 'select[name="trackState"]',
marbles: '.switch:has(input[name="marbles"])',
rubberedTrack: '.switch:has(input[name="rubberedTrack"])',
},
buttons: {
// NOTE: Checkout button is intentionally NOT included for safety
// The automation should stop here and let the user review/confirm manually
back: '.wizard-footer .btn:has-text("Race Options")',
},
},
},
};
/**
* Get selectors for a specific step
*/
export function getStepSelectors(stepId: number): StepSelectors | undefined {
return IRacingSelectorMap.steps[stepId];
}
/**
* Check if a step is a modal step (requires opening a secondary dialog)
*/
export function isModalStep(stepId: number): boolean {
return stepId === 6 || stepId === 9 || stepId === 12;
}
/**
* 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}`;
}

View File

@@ -5,33 +5,21 @@
* allowing switching between different adapters based on NODE_ENV.
*
* Mapping:
* - NODE_ENV=development → BrowserDevToolsAdapter → Fixture Server → CSS Selectors
* - NODE_ENV=production → NutJsAutomationAdapter → iRacing Window → Image Templates
* - NODE_ENV=test → MockBrowserAutomation → N/A → N/A
* - NODE_ENV=development → MockBrowserAutomation → N/A → N/A
*/
export type AutomationMode = 'development' | 'production' | 'test';
export type AutomationMode = 'production' | 'test';
/**
* @deprecated Use AutomationMode instead. Will be removed in future version.
*/
export type LegacyAutomationMode = 'dev' | 'production' | 'mock';
export interface FixtureServerConfig {
port: number;
autoStart: boolean;
fixturesPath: string;
}
export interface AutomationEnvironmentConfig {
mode: AutomationMode;
/** Development mode configuration (Browser DevTools with fixture server) */
devTools?: {
browserWSEndpoint?: string;
debuggingPort?: number;
};
/** Production mode configuration (nut.js) */
nutJs?: {
mouseSpeed?: number;
@@ -41,9 +29,6 @@ export interface AutomationEnvironmentConfig {
confidence?: number;
};
/** Fixture server configuration for development mode */
fixtureServer?: FixtureServerConfig;
/** Default timeout for automation operations in milliseconds */
defaultTimeout?: number;
/** Number of retry attempts for failed operations */
@@ -57,8 +42,7 @@ export interface AutomationEnvironmentConfig {
*
* Mapping:
* - NODE_ENV=production → 'production'
* - NODE_ENV=test → 'test'
* - NODE_ENV=development → 'development' (default)
* - All other values → 'test' (default)
*
* For backward compatibility, if AUTOMATION_MODE is explicitly set,
* it will be used with a deprecation warning logged to console.
@@ -70,34 +54,28 @@ export function getAutomationMode(): AutomationMode {
if (legacyMode && isValidLegacyAutomationMode(legacyMode)) {
console.warn(
`[DEPRECATED] AUTOMATION_MODE environment variable is deprecated. ` +
`Use NODE_ENV instead. Mapping: dev→development, mock→test, production→production`
`Use NODE_ENV instead. Mapping: dev→test, mock→test, production→production`
);
return mapLegacyMode(legacyMode);
}
const nodeEnv = process.env.NODE_ENV;
if (nodeEnv === 'production') return 'production';
if (nodeEnv === 'test') return 'test';
return 'development';
return 'test';
}
/**
* Load automation configuration from environment variables.
*
* Environment variables:
* - NODE_ENV: 'development' | 'production' | 'test' (default: 'development')
* - NODE_ENV: 'production' | 'test' (default: 'test')
* - AUTOMATION_MODE: (deprecated) 'dev' | 'production' | 'mock'
* - CHROME_DEBUG_PORT: Chrome debugging port (default: 9222)
* - CHROME_WS_ENDPOINT: WebSocket endpoint for Chrome DevTools
* - IRACING_WINDOW_TITLE: Window title for nut.js (default: 'iRacing')
* - TEMPLATE_PATH: Path to template images (default: './resources/templates')
* - OCR_CONFIDENCE: OCR confidence threshold (default: 0.9)
* - AUTOMATION_TIMEOUT: Default timeout in ms (default: 30000)
* - RETRY_ATTEMPTS: Number of retry attempts (default: 3)
* - SCREENSHOT_ON_ERROR: Capture screenshots on error (default: true)
* - FIXTURE_SERVER_PORT: Port for fixture server (default: 3456)
* - FIXTURE_SERVER_AUTO_START: Auto-start fixture server (default: true in development)
* - FIXTURE_SERVER_PATH: Path to fixtures (default: './resources/iracing-hosted-sessions')
*
* @returns AutomationEnvironmentConfig with parsed environment values
*/
@@ -106,10 +84,6 @@ export function loadAutomationConfig(): AutomationEnvironmentConfig {
return {
mode,
devTools: {
debuggingPort: parseIntSafe(process.env.CHROME_DEBUG_PORT, 9222),
browserWSEndpoint: process.env.CHROME_WS_ENDPOINT,
},
nutJs: {
mouseSpeed: parseIntSafe(process.env.NUTJS_MOUSE_SPEED, 1000),
keyboardDelay: parseIntSafe(process.env.NUTJS_KEYBOARD_DELAY, 50),
@@ -117,11 +91,6 @@ export function loadAutomationConfig(): AutomationEnvironmentConfig {
templatePath: process.env.TEMPLATE_PATH || './resources/templates',
confidence: parseFloatSafe(process.env.OCR_CONFIDENCE, 0.9),
},
fixtureServer: {
port: parseIntSafe(process.env.FIXTURE_SERVER_PORT, 3456),
autoStart: process.env.FIXTURE_SERVER_AUTO_START !== 'false' && mode === 'development',
fixturesPath: process.env.FIXTURE_SERVER_PATH || './resources/iracing-hosted-sessions',
},
defaultTimeout: parseIntSafe(process.env.AUTOMATION_TIMEOUT, 30000),
retryAttempts: parseIntSafe(process.env.RETRY_ATTEMPTS, 3),
screenshotOnError: process.env.SCREENSHOT_ON_ERROR !== 'false',
@@ -132,7 +101,7 @@ export function loadAutomationConfig(): AutomationEnvironmentConfig {
* Type guard to validate automation mode string.
*/
function isValidAutomationMode(value: string | undefined): value is AutomationMode {
return value === 'development' || value === 'production' || value === 'test';
return value === 'production' || value === 'test';
}
/**
@@ -147,7 +116,7 @@ function isValidLegacyAutomationMode(value: string | undefined): value is Legacy
*/
function mapLegacyMode(legacy: LegacyAutomationMode): AutomationMode {
switch (legacy) {
case 'dev': return 'development';
case 'dev': return 'test';
case 'mock': return 'test';
case 'production': return 'production';
}

View File

@@ -2,10 +2,5 @@
* Configuration module exports for infrastructure layer.
*/
export {
AutomationMode,
AutomationEnvironmentConfig,
FixtureServerConfig,
loadAutomationConfig,
getAutomationMode,
} from './AutomationConfig';
export type { AutomationMode, AutomationEnvironmentConfig } from './AutomationConfig';
export { loadAutomationConfig, getAutomationMode } from './AutomationConfig';

View File

@@ -1,6 +1,6 @@
import { AutomationSession } from '../../packages/domain/entities/AutomationSession';
import { SessionStateValue } from '../../packages/domain/value-objects/SessionState';
import { ISessionRepository } from '../../packages/application/ports/ISessionRepository';
import { AutomationSession } from '../../domain/entities/AutomationSession';
import { SessionStateValue } from '../../domain/value-objects/SessionState';
import { ISessionRepository } from '../../application/ports/ISessionRepository';
export class InMemorySessionRepository implements ISessionRepository {
private sessions: Map<string, AutomationSession> = new Map();