refactor(automation): remove browser automation, use OS-level automation only
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user