refactor: restructure to monorepo with apps and packages directories - Move companion app to apps/companion with electron-vite - Move domain/application/infrastructure to packages/ - Fix ELECTRON_RUN_AS_NODE env var issue for VS Code terminal - Remove legacy esbuild bundler (replaced by electron-vite) - Update workspace scripts in root package.json

This commit is contained in:
2025-11-22 00:25:06 +01:00
parent d20554df55
commit 7eae6e3bd4
39 changed files with 515 additions and 1939 deletions

View File

@@ -0,0 +1,30 @@
export interface AutomationResult {
success: boolean;
error?: string;
metadata?: Record<string, unknown>;
}
export interface NavigationResult extends AutomationResult {
url: string;
loadTime: number;
}
export interface FormFillResult extends AutomationResult {
fieldName: string;
valueSet: string;
}
export interface ClickResult extends AutomationResult {
target: string;
}
export interface WaitResult extends AutomationResult {
target: string;
waitedMs: number;
found: boolean;
}
export interface ModalResult extends AutomationResult {
stepId: number;
action: string;
}

View File

@@ -0,0 +1,12 @@
import { HostedSessionConfig } from '../../domain/entities/HostedSessionConfig';
import { StepId } from '../../domain/value-objects/StepId';
export interface ValidationResult {
isValid: boolean;
error?: string;
}
export interface IAutomationEngine {
validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult>;
executeStep(stepId: StepId, config: HostedSessionConfig): Promise<void>;
}

View File

@@ -0,0 +1,31 @@
import { StepId } from '../../domain/value-objects/StepId';
import {
NavigationResult,
FormFillResult,
ClickResult,
WaitResult,
ModalResult,
AutomationResult,
} from './AutomationResults';
export interface IBrowserAutomation {
navigateToPage(url: string): Promise<NavigationResult>;
fillFormField(fieldName: string, value: string): Promise<FormFillResult>;
clickElement(selector: string): Promise<ClickResult>;
waitForElement(selector: string, maxWaitMs?: number): Promise<WaitResult>;
handleModal(stepId: StepId, action: string): Promise<ModalResult>;
/**
* Execute a complete workflow step with all required browser operations.
* Uses IRacingSelectorMap to locate elements and performs appropriate actions.
*
* @param stepId - The step to execute (1-18)
* @param config - Session configuration with form field values
* @returns AutomationResult with success/failure and metadata
*/
executeStep?(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult>;
connect?(): Promise<void>;
disconnect?(): Promise<void>;
isConnected?(): boolean;
}

View File

@@ -0,0 +1,11 @@
import { AutomationSession } from '../../domain/entities/AutomationSession';
import { SessionStateValue } from '../../domain/value-objects/SessionState';
export interface ISessionRepository {
save(session: AutomationSession): Promise<void>;
findById(id: string): Promise<AutomationSession | null>;
update(session: AutomationSession): Promise<void>;
delete(id: string): Promise<void>;
findAll(): Promise<AutomationSession[]>;
findByState(state: SessionStateValue): Promise<AutomationSession[]>;
}

View File

@@ -0,0 +1,44 @@
import { AutomationSession } from '../../domain/entities/AutomationSession';
import { HostedSessionConfig } from '../../domain/entities/HostedSessionConfig';
import { IAutomationEngine } from '../ports/IAutomationEngine';
import { IBrowserAutomation } from '../ports/IBrowserAutomation';
import { ISessionRepository } from '../ports/ISessionRepository';
export interface SessionDTO {
sessionId: string;
state: string;
currentStep: number;
config: HostedSessionConfig;
startedAt?: Date;
completedAt?: Date;
errorMessage?: string;
}
export class StartAutomationSessionUseCase {
constructor(
private readonly automationEngine: IAutomationEngine,
private readonly browserAutomation: IBrowserAutomation,
private readonly sessionRepository: ISessionRepository
) {}
async execute(config: HostedSessionConfig): Promise<SessionDTO> {
const session = AutomationSession.create(config);
const validationResult = await this.automationEngine.validateConfiguration(config);
if (!validationResult.isValid) {
throw new Error(validationResult.error);
}
await this.sessionRepository.save(session);
return {
sessionId: session.id,
state: session.state.value,
currentStep: session.currentStep.value,
config: session.config,
startedAt: session.startedAt,
completedAt: session.completedAt,
errorMessage: session.errorMessage,
};
}
}

View File

@@ -0,0 +1,143 @@
import { randomUUID } from 'crypto';
import { StepId } from '../value-objects/StepId';
import { SessionState } from '../value-objects/SessionState';
import { HostedSessionConfig } from './HostedSessionConfig';
export class AutomationSession {
private readonly _id: string;
private _currentStep: StepId;
private _state: SessionState;
private readonly _config: HostedSessionConfig;
private _startedAt?: Date;
private _completedAt?: Date;
private _errorMessage?: string;
private constructor(
id: string,
currentStep: StepId,
state: SessionState,
config: HostedSessionConfig
) {
this._id = id;
this._currentStep = currentStep;
this._state = state;
this._config = config;
}
static create(config: HostedSessionConfig): AutomationSession {
if (!config.sessionName || config.sessionName.trim() === '') {
throw new Error('Session name cannot be empty');
}
if (!config.trackId || config.trackId.trim() === '') {
throw new Error('Track ID is required');
}
if (!config.carIds || config.carIds.length === 0) {
throw new Error('At least one car must be selected');
}
return new AutomationSession(
randomUUID(),
StepId.create(1),
SessionState.create('PENDING'),
config
);
}
get id(): string {
return this._id;
}
get currentStep(): StepId {
return this._currentStep;
}
get state(): SessionState {
return this._state;
}
get config(): HostedSessionConfig {
return this._config;
}
get startedAt(): Date | undefined {
return this._startedAt;
}
get completedAt(): Date | undefined {
return this._completedAt;
}
get errorMessage(): string | undefined {
return this._errorMessage;
}
start(): void {
if (!this._state.isPending()) {
throw new Error('Cannot start session that is not pending');
}
this._state = SessionState.create('IN_PROGRESS');
this._startedAt = new Date();
}
transitionToStep(targetStep: StepId): void {
if (!this._state.isInProgress()) {
throw new Error('Cannot transition steps when session is not in progress');
}
if (this._currentStep.equals(targetStep)) {
throw new Error('Already at this step');
}
if (targetStep.value < this._currentStep.value) {
throw new Error('Cannot move backward - steps must progress forward only');
}
if (targetStep.value !== this._currentStep.value + 1) {
throw new Error('Cannot skip steps - must transition sequentially');
}
this._currentStep = targetStep;
if (this._currentStep.isFinalStep()) {
this._state = SessionState.create('STOPPED_AT_STEP_18');
this._completedAt = new Date();
}
}
pause(): void {
if (!this._state.isInProgress()) {
throw new Error('Cannot pause session that is not in progress');
}
this._state = SessionState.create('PAUSED');
}
resume(): void {
if (this._state.value !== 'PAUSED') {
throw new Error('Cannot resume session that is not paused');
}
this._state = SessionState.create('IN_PROGRESS');
}
fail(errorMessage: string): void {
if (this._state.isTerminal()) {
throw new Error('Cannot fail terminal session');
}
this._state = SessionState.create('FAILED');
this._errorMessage = errorMessage;
this._completedAt = new Date();
}
isAtModalStep(): boolean {
return this._currentStep.isModalStep();
}
getElapsedTime(): number {
if (!this._startedAt) {
return 0;
}
const endTime = this._completedAt || new Date();
const elapsed = endTime.getTime() - this._startedAt.getTime();
return elapsed > 0 ? elapsed : 1;
}
}

View File

@@ -0,0 +1,20 @@
export interface HostedSessionConfig {
sessionName: string;
serverName: string;
password: string;
adminPassword: string;
maxDrivers: number;
trackId: string;
carIds: string[];
weatherType: 'static' | 'dynamic';
timeOfDay: 'morning' | 'afternoon' | 'evening' | 'night';
sessionDuration: number;
practiceLength: number;
qualifyingLength: number;
warmupLength: number;
raceLength: number;
startType: 'standing' | 'rolling';
restarts: 'single-file' | 'double-file';
damageModel: 'off' | 'limited' | 'realistic';
trackState: 'auto' | 'clean' | 'moderately-low' | 'moderately-high' | 'optimum';
}

View File

@@ -0,0 +1,9 @@
import { StepId } from '../value-objects/StepId';
export interface StepExecution {
stepId: StepId;
startedAt: Date;
completedAt?: Date;
success: boolean;
error?: string;
}

View File

@@ -0,0 +1,81 @@
import { StepId } from '../value-objects/StepId';
import { SessionState } from '../value-objects/SessionState';
export interface ValidationResult {
isValid: boolean;
error?: string;
}
const STEP_DESCRIPTIONS: Record<number, string> = {
1: 'Navigate to Hosted Racing page',
2: 'Click Create a Race',
3: 'Fill Race Information',
4: 'Configure Server Details',
5: 'Set Admins',
6: 'Add Admin (Modal)',
7: 'Set Time Limits',
8: 'Set Cars',
9: 'Add a Car (Modal)',
10: 'Set Car Classes',
11: 'Set Track',
12: 'Add a Track (Modal)',
13: 'Configure Track Options',
14: 'Set Time of Day',
15: 'Configure Weather',
16: 'Set Race Options',
17: 'Configure Team Driving',
18: 'Track Conditions (STOP - Manual Submit Required)',
};
export class StepTransitionValidator {
static canTransition(
currentStep: StepId,
nextStep: StepId,
state: SessionState
): ValidationResult {
if (!state.isInProgress()) {
return {
isValid: false,
error: 'Session must be in progress to transition steps',
};
}
if (currentStep.equals(nextStep)) {
return {
isValid: false,
error: 'Already at this step',
};
}
if (nextStep.value < currentStep.value) {
return {
isValid: false,
error: 'Cannot move backward - steps must progress forward only',
};
}
if (nextStep.value !== currentStep.value + 1) {
return {
isValid: false,
error: 'Cannot skip steps - must progress sequentially',
};
}
return { isValid: true };
}
static validateModalStepTransition(
currentStep: StepId,
nextStep: StepId
): ValidationResult {
return { isValid: true };
}
static shouldStopAtStep18(nextStep: StepId): boolean {
return nextStep.isFinalStep();
}
static getStepDescription(step: StepId): string {
return STEP_DESCRIPTIONS[step.value] || `Step ${step.value}`;
}
}

View File

@@ -0,0 +1,81 @@
export type SessionStateValue =
| 'PENDING'
| 'IN_PROGRESS'
| 'PAUSED'
| 'COMPLETED'
| 'FAILED'
| 'STOPPED_AT_STEP_18';
const VALID_STATES: SessionStateValue[] = [
'PENDING',
'IN_PROGRESS',
'PAUSED',
'COMPLETED',
'FAILED',
'STOPPED_AT_STEP_18',
];
const VALID_TRANSITIONS: Record<SessionStateValue, SessionStateValue[]> = {
PENDING: ['IN_PROGRESS', 'FAILED'],
IN_PROGRESS: ['PAUSED', 'COMPLETED', 'FAILED', 'STOPPED_AT_STEP_18'],
PAUSED: ['IN_PROGRESS', 'FAILED'],
COMPLETED: [],
FAILED: [],
STOPPED_AT_STEP_18: [],
};
export class SessionState {
private readonly _value: SessionStateValue;
private constructor(value: SessionStateValue) {
this._value = value;
}
static create(value: SessionStateValue): SessionState {
if (!VALID_STATES.includes(value)) {
throw new Error('Invalid session state');
}
return new SessionState(value);
}
get value(): SessionStateValue {
return this._value;
}
equals(other: SessionState): boolean {
return this._value === other._value;
}
isPending(): boolean {
return this._value === 'PENDING';
}
isInProgress(): boolean {
return this._value === 'IN_PROGRESS';
}
isCompleted(): boolean {
return this._value === 'COMPLETED';
}
isFailed(): boolean {
return this._value === 'FAILED';
}
isStoppedAtStep18(): boolean {
return this._value === 'STOPPED_AT_STEP_18';
}
canTransitionTo(targetState: SessionState): boolean {
const allowedTransitions = VALID_TRANSITIONS[this._value];
return allowedTransitions.includes(targetState._value);
}
isTerminal(): boolean {
return (
this._value === 'COMPLETED' ||
this._value === 'FAILED' ||
this._value === 'STOPPED_AT_STEP_18'
);
}
}

View File

@@ -0,0 +1,40 @@
export class StepId {
private readonly _value: number;
private constructor(value: number) {
this._value = value;
}
static create(value: number): StepId {
if (!Number.isInteger(value)) {
throw new Error('StepId must be an integer');
}
if (value < 1 || value > 18) {
throw new Error('StepId must be between 1 and 18');
}
return new StepId(value);
}
get value(): number {
return this._value;
}
equals(other: StepId): boolean {
return this._value === other._value;
}
isModalStep(): boolean {
return this._value === 6 || this._value === 9 || this._value === 12;
}
isFinalStep(): boolean {
return this._value === 18;
}
next(): StepId {
if (this.isFinalStep()) {
throw new Error('Cannot advance beyond final step');
}
return StepId.create(this._value + 1);
}
}

View File

@@ -0,0 +1,973 @@
import puppeteer, { Browser, Page, CDPSession } from 'puppeteer-core';
import { StepId } from '../../../packages/domain/value-objects/StepId';
import { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation';
import {
NavigationResult,
FormFillResult,
ClickResult,
WaitResult,
ModalResult,
AutomationResult,
} from '../../../packages/application/ports/AutomationResults';
import { IRacingSelectorMap, getStepSelectors, getStepName, isModalStep } from './selectors/IRacingSelectorMap';
/**
* Configuration for connecting to browser via Chrome DevTools Protocol
*/
export interface DevToolsConfig {
/** WebSocket endpoint URL (e.g., ws://127.0.0.1:9222/devtools/browser/...) */
browserWSEndpoint?: string;
/** Chrome debugging port (default: 9222) */
debuggingPort?: number;
/** Default timeout for operations in milliseconds (default: 30000) */
defaultTimeout?: number;
/** Human-like typing delay in milliseconds (default: 50) */
typingDelay?: number;
/** Whether to wait for network idle after navigation (default: true) */
waitForNetworkIdle?: boolean;
}
/**
* BrowserDevToolsAdapter - Real browser automation using Puppeteer-core.
*
* This adapter connects to an existing browser session via Chrome DevTools Protocol (CDP)
* and automates the iRacing hosted session creation workflow.
*
* Key features:
* - Connects to existing browser (doesn't launch new one)
* - Uses IRacingSelectorMap for element location
* - Human-like typing delays for form filling
* - Waits for network idle after navigation
* - Disconnects without closing browser
*
* Usage:
* 1. Start Chrome with remote debugging:
* `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222`
* 2. Navigate to iRacing and log in manually
* 3. Create adapter and connect:
* ```
* const adapter = new BrowserDevToolsAdapter({ debuggingPort: 9222 });
* await adapter.connect();
* ```
*/
export class BrowserDevToolsAdapter implements IBrowserAutomation {
private browser: Browser | null = null;
private page: Page | null = null;
private config: Required<DevToolsConfig>;
private connected: boolean = false;
constructor(config: DevToolsConfig = {}) {
this.config = {
browserWSEndpoint: config.browserWSEndpoint ?? '',
debuggingPort: config.debuggingPort ?? 9222,
defaultTimeout: config.defaultTimeout ?? 30000,
typingDelay: config.typingDelay ?? 50,
waitForNetworkIdle: config.waitForNetworkIdle ?? true,
};
}
/**
* Connect to an existing browser via Chrome DevTools Protocol.
* The browser must be started with --remote-debugging-port flag.
*/
async connect(): Promise<void> {
if (this.connected) {
return;
}
try {
if (this.config.browserWSEndpoint) {
// Connect using explicit WebSocket endpoint
this.browser = await puppeteer.connect({
browserWSEndpoint: this.config.browserWSEndpoint,
});
} else {
// Connect using debugging port - need to fetch endpoint first
const response = await fetch(`http://127.0.0.1:${this.config.debuggingPort}/json/version`);
const data = await response.json();
const wsEndpoint = data.webSocketDebuggerUrl;
this.browser = await puppeteer.connect({
browserWSEndpoint: wsEndpoint,
});
}
// Find iRacing tab or use the first available tab
const pages = await this.browser.pages();
this.page = await this.findIRacingPage(pages) || pages[0];
if (!this.page) {
throw new Error('No pages found in browser');
}
// Set default timeout
this.page.setDefaultTimeout(this.config.defaultTimeout);
this.connected = true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to connect to browser: ${errorMessage}`);
}
}
/**
* Disconnect from the browser without closing it.
* The user can continue using the browser after disconnection.
*/
async disconnect(): Promise<void> {
if (this.browser) {
// Disconnect without closing - user may still use the browser
this.browser.disconnect();
this.browser = null;
this.page = null;
}
this.connected = false;
}
/**
* Check if adapter is connected to browser.
*/
isConnected(): boolean {
return this.connected && this.browser !== null && this.page !== null;
}
/**
* Navigate to a URL and wait for the page to load.
*/
async navigateToPage(url: string): Promise<NavigationResult> {
this.ensureConnected();
const startTime = Date.now();
try {
const waitUntil = this.config.waitForNetworkIdle ? 'networkidle2' : 'domcontentloaded';
await this.page!.goto(url, { waitUntil });
const loadTime = Date.now() - startTime;
return {
success: true,
url,
loadTime,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
url,
loadTime: Date.now() - startTime,
error: `Navigation failed: ${errorMessage}`,
};
}
}
/**
* Fill a form field with human-like typing delay.
*
* @param fieldName - Field identifier (will be looked up in selector map or used directly)
* @param value - Value to type into the field
*/
async fillFormField(fieldName: string, value: string): Promise<FormFillResult> {
this.ensureConnected();
try {
// Try to find the element
const element = await this.page!.$(fieldName);
if (!element) {
return {
success: false,
fieldName,
valueSet: '',
error: `Field not found: ${fieldName}`,
};
}
// Clear existing value and type new value with human-like delay
await element.click({ clickCount: 3 }); // Select all existing text
await element.type(value, { delay: this.config.typingDelay });
return {
success: true,
fieldName,
valueSet: value,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
fieldName,
valueSet: '',
error: `Failed to fill field: ${errorMessage}`,
};
}
}
/**
* Click an element on the page.
*/
async clickElement(selector: string): Promise<ClickResult> {
this.ensureConnected();
try {
// Wait for element to be visible and clickable
await this.page!.waitForSelector(selector, {
visible: true,
timeout: this.config.defaultTimeout
});
await this.page!.click(selector);
return {
success: true,
target: selector,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
target: selector,
error: `Click failed: ${errorMessage}`,
};
}
}
/**
* Wait for an element to appear on the page.
*/
async waitForElement(selector: string, maxWaitMs: number = 5000): Promise<WaitResult> {
this.ensureConnected();
const startTime = Date.now();
try {
await this.page!.waitForSelector(selector, {
timeout: maxWaitMs,
visible: true
});
return {
success: true,
target: selector,
waitedMs: Date.now() - startTime,
found: true,
};
} catch (error) {
return {
success: false,
target: selector,
waitedMs: Date.now() - startTime,
found: false,
error: `Element not found within ${maxWaitMs}ms`,
};
}
}
/**
* Handle modal operations for specific workflow steps.
* Modal steps are: 6 (SET_ADMINS), 9 (ADD_CAR), 12 (ADD_TRACK)
*/
async handleModal(stepId: StepId, action: string): Promise<ModalResult> {
this.ensureConnected();
if (!stepId.isModalStep()) {
return {
success: false,
stepId: stepId.value,
action,
error: `Step ${stepId.value} (${getStepName(stepId.value)}) is not a modal step`,
};
}
try {
const stepSelectors = getStepSelectors(stepId.value);
if (!stepSelectors?.modal) {
return {
success: false,
stepId: stepId.value,
action,
error: `No modal selectors defined for step ${stepId.value}`,
};
}
const modalSelectors = stepSelectors.modal;
switch (action) {
case 'open':
// Wait for and verify modal is open
await this.page!.waitForSelector(modalSelectors.container, {
visible: true,
timeout: this.config.defaultTimeout,
});
break;
case 'close':
// Click close button
await this.page!.click(modalSelectors.closeButton);
// Wait for modal to disappear
await this.page!.waitForSelector(modalSelectors.container, {
hidden: true,
timeout: this.config.defaultTimeout,
});
break;
case 'search':
// Focus search input if available
if (modalSelectors.searchInput) {
await this.page!.click(modalSelectors.searchInput);
}
break;
case 'select':
// Click select/confirm button
if (modalSelectors.selectButton) {
await this.page!.click(modalSelectors.selectButton);
}
break;
default:
return {
success: false,
stepId: stepId.value,
action,
error: `Unknown modal action: ${action}`,
};
}
return {
success: true,
stepId: stepId.value,
action,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
stepId: stepId.value,
action,
error: `Modal operation failed: ${errorMessage}`,
};
}
}
// ============== Helper Methods ==============
/**
* Find the iRacing page among open browser tabs.
*/
private async findIRacingPage(pages: Page[]): Promise<Page | null> {
for (const page of pages) {
const url = page.url();
if (url.includes('iracing.com') || url.includes('members-ng.iracing.com')) {
return page;
}
}
return null;
}
/**
* Ensure adapter is connected before operations.
*/
private ensureConnected(): void {
if (!this.isConnected()) {
throw new Error('Not connected to browser. Call connect() first.');
}
}
// ============== Extended Methods for Workflow Automation ==============
/**
* Navigate to a specific step in the wizard using sidebar navigation.
*/
async navigateToStep(stepId: StepId): Promise<NavigationResult> {
this.ensureConnected();
const startTime = Date.now();
const stepSelectors = getStepSelectors(stepId.value);
if (!stepSelectors?.sidebarLink) {
return {
success: false,
url: '',
loadTime: 0,
error: `No sidebar link defined for step ${stepId.value} (${getStepName(stepId.value)})`,
};
}
try {
await this.page!.click(stepSelectors.sidebarLink);
// Wait for step container to be visible
if (stepSelectors.container) {
await this.page!.waitForSelector(stepSelectors.container, { visible: true });
}
return {
success: true,
url: this.page!.url(),
loadTime: Date.now() - startTime,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
url: this.page!.url(),
loadTime: Date.now() - startTime,
error: `Failed to navigate to step: ${errorMessage}`,
};
}
}
/**
* Get the current page URL.
*/
getCurrentUrl(): string {
if (!this.page) {
return '';
}
return this.page.url();
}
/**
* Take a screenshot of the current page (useful for debugging).
*/
async takeScreenshot(path: string): Promise<void> {
this.ensureConnected();
await this.page!.screenshot({ path, fullPage: true });
}
/**
* Get the current page content (useful for debugging).
*/
async getPageContent(): Promise<string> {
this.ensureConnected();
return await this.page!.content();
}
/**
* Wait for network to be idle (no pending requests).
*/
async waitForNetworkIdle(timeout: number = 5000): Promise<void> {
this.ensureConnected();
await this.page!.waitForNetworkIdle({ timeout });
}
/**
* Execute JavaScript in the page context.
*/
async evaluate<T>(fn: () => T): Promise<T> {
this.ensureConnected();
return await this.page!.evaluate(fn);
}
// ============== Step Execution ==============
/**
* Execute a complete workflow step with all required browser operations.
* Uses IRacingSelectorMap to locate elements and performs appropriate actions.
*
* Step workflow:
* 1. LOGIN - Skip (user pre-authenticated)
* 2. HOSTED_RACING - Navigate to hosted racing page
* 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 - Add admins (modal step)
* 7. TIME_LIMITS - Set practice/qualify/race times
* 8. SET_CARS - Configure car selection
* 9. ADD_CAR - Add cars (modal step)
* 10. SET_CAR_CLASSES - Configure car classes
* 11. SET_TRACK - Select track
* 12. ADD_TRACK - Add track (modal step)
* 13. TRACK_OPTIONS - Track configuration
* 14. TIME_OF_DAY - Set time of day
* 15. WEATHER - Weather settings
* 16. RACE_OPTIONS - Race rules and options
* 17. TEAM_DRIVING - Team settings
* 18. TRACK_CONDITIONS - Final review (SAFETY STOP - no checkout)
*
* @param stepId - The step to execute (1-18)
* @param config - Session configuration with form field values
* @returns AutomationResult with success/failure and metadata
*/
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
this.ensureConnected();
const stepNumber = stepId.value;
const stepSelectors = getStepSelectors(stepNumber);
const stepName = getStepName(stepNumber);
try {
switch (stepNumber) {
case 1: // LOGIN - Skip, user already authenticated
return {
success: true,
metadata: {
skipped: true,
reason: 'User pre-authenticated',
step: stepName
}
};
case 2: // HOSTED_RACING - Navigate to hosted racing page
return await this.executeHostedRacingStep();
case 3: // CREATE_RACE - Click create race button
return await this.executeCreateRaceStep(stepSelectors);
case 4: // RACE_INFORMATION - Fill session details
return await this.executeRaceInformationStep(stepSelectors, config);
case 5: // SERVER_DETAILS - Configure server settings
return await this.executeServerDetailsStep(stepSelectors, config);
case 6: // SET_ADMINS - Add admins (modal step)
return await this.executeSetAdminsStep(stepSelectors, config);
case 7: // TIME_LIMITS - Configure time limits
return await this.executeTimeLimitsStep(stepSelectors, config);
case 8: // SET_CARS - Configure car selection
return await this.executeSetCarsStep(stepSelectors);
case 9: // ADD_CAR - Add cars (modal step)
return await this.executeAddCarStep(stepSelectors, config);
case 10: // SET_CAR_CLASSES - Configure car classes
return await this.executeSetCarClassesStep(stepSelectors, config);
case 11: // SET_TRACK - Select track
return await this.executeSetTrackStep(stepSelectors);
case 12: // ADD_TRACK - Add track (modal step)
return await this.executeAddTrackStep(stepSelectors, config);
case 13: // TRACK_OPTIONS - Track configuration
return await this.executeTrackOptionsStep(stepSelectors, config);
case 14: // TIME_OF_DAY - Set time of day
return await this.executeTimeOfDayStep(stepSelectors, config);
case 15: // WEATHER - Weather settings
return await this.executeWeatherStep(stepSelectors, config);
case 16: // RACE_OPTIONS - Race rules and options
return await this.executeRaceOptionsStep(stepSelectors, config);
case 17: // TEAM_DRIVING - Team settings
return await this.executeTeamDrivingStep(stepSelectors, config);
case 18: // TRACK_CONDITIONS - Final review (SAFETY STOP)
return await this.executeTrackConditionsStep(stepSelectors, config);
default:
return {
success: false,
error: `Unknown step: ${stepNumber}`,
metadata: { step: stepName }
};
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
error: errorMessage,
metadata: { step: stepName }
};
}
}
// ============== Individual Step Implementations ==============
private async executeHostedRacingStep(): Promise<AutomationResult> {
const navResult = await this.navigateToPage(IRacingSelectorMap.urls.hostedRacing);
if (!navResult.success) {
return { success: false, error: navResult.error, metadata: { step: 'HOSTED_RACING' } };
}
// Wait for page to be ready
const stepSelectors = getStepSelectors(2);
if (stepSelectors?.container) {
await this.waitForElement(stepSelectors.container, this.config.defaultTimeout);
}
return {
success: true,
metadata: { step: 'HOSTED_RACING', url: IRacingSelectorMap.urls.hostedRacing }
};
}
private async executeCreateRaceStep(stepSelectors: ReturnType<typeof getStepSelectors>): Promise<AutomationResult> {
if (!stepSelectors?.buttons?.createRace) {
return { success: false, error: 'Create race button selector not defined', metadata: { step: 'CREATE_RACE' } };
}
const clickResult = await this.clickElement(stepSelectors.buttons.createRace);
if (!clickResult.success) {
return { success: false, error: clickResult.error, metadata: { step: 'CREATE_RACE' } };
}
// Wait for wizard modal to appear
const waitResult = await this.waitForElement(IRacingSelectorMap.common.wizardContainer, this.config.defaultTimeout);
if (!waitResult.success) {
return { success: false, error: 'Wizard did not open', metadata: { step: 'CREATE_RACE' } };
}
return { success: true, metadata: { step: 'CREATE_RACE' } };
}
private async executeRaceInformationStep(
stepSelectors: ReturnType<typeof getStepSelectors>,
config: Record<string, unknown>
): Promise<AutomationResult> {
// Fill session name if provided
if (config.sessionName && stepSelectors?.fields?.sessionName) {
const fillResult = await this.fillFormField(stepSelectors.fields.sessionName, config.sessionName as string);
if (!fillResult.success) {
return { success: false, error: fillResult.error, metadata: { step: 'RACE_INFORMATION', field: 'sessionName' } };
}
}
// Fill password if provided
if (config.password && stepSelectors?.fields?.password) {
const fillResult = await this.fillFormField(stepSelectors.fields.password, config.password as string);
if (!fillResult.success) {
return { success: false, error: fillResult.error, metadata: { step: 'RACE_INFORMATION', field: 'password' } };
}
}
// Fill description if provided
if (config.description && stepSelectors?.fields?.description) {
const fillResult = await this.fillFormField(stepSelectors.fields.description, config.description as string);
if (!fillResult.success) {
return { success: false, error: fillResult.error, metadata: { step: 'RACE_INFORMATION', field: 'description' } };
}
}
// Click next button to proceed
if (stepSelectors?.buttons?.next) {
const clickResult = await this.clickElement(stepSelectors.buttons.next);
if (!clickResult.success) {
return { success: false, error: clickResult.error, metadata: { step: 'RACE_INFORMATION', action: 'next' } };
}
}
return { success: true, metadata: { step: 'RACE_INFORMATION' } };
}
private async executeServerDetailsStep(
stepSelectors: ReturnType<typeof getStepSelectors>,
config: Record<string, unknown>
): Promise<AutomationResult> {
// Server region selection would require dropdown interaction
// For now, accept defaults unless specific configuration is provided
// Click next button to proceed
if (stepSelectors?.buttons?.next) {
const clickResult = await this.clickElement(stepSelectors.buttons.next);
if (!clickResult.success) {
return { success: false, error: clickResult.error, metadata: { step: 'SERVER_DETAILS', action: 'next' } };
}
}
return { success: true, metadata: { step: 'SERVER_DETAILS' } };
}
private async executeSetAdminsStep(
stepSelectors: ReturnType<typeof getStepSelectors>,
config: Record<string, unknown>
): Promise<AutomationResult> {
// Admin step is a modal step - check if we need to add admins
const adminIds = config.adminIds as string[] | undefined;
if (adminIds && adminIds.length > 0 && stepSelectors?.modal) {
// Open admin modal
if (stepSelectors.buttons?.addAdmin) {
const clickResult = await this.clickElement(stepSelectors.buttons.addAdmin);
if (!clickResult.success) {
return { success: false, error: clickResult.error, metadata: { step: 'SET_ADMINS', action: 'openModal' } };
}
// Wait for modal to appear
await this.waitForElement(stepSelectors.modal.container, this.config.defaultTimeout);
// Search and select admins would require more complex interaction
// For now, close the modal
if (stepSelectors.modal.closeButton) {
await this.clickElement(stepSelectors.modal.closeButton);
}
}
}
// Click next button to proceed
if (stepSelectors?.buttons?.next) {
const clickResult = await this.clickElement(stepSelectors.buttons.next);
if (!clickResult.success) {
return { success: false, error: clickResult.error, metadata: { step: 'SET_ADMINS', action: 'next' } };
}
}
return { success: true, metadata: { step: 'SET_ADMINS', isModalStep: true } };
}
private async executeTimeLimitsStep(
stepSelectors: ReturnType<typeof getStepSelectors>,
config: Record<string, unknown>
): Promise<AutomationResult> {
// Fill practice length if provided
if (config.practiceLength && stepSelectors?.fields?.practiceLength) {
await this.fillFormField(stepSelectors.fields.practiceLength, String(config.practiceLength));
}
// Fill qualify length if provided
if (config.qualifyingLength && stepSelectors?.fields?.qualifyLength) {
await this.fillFormField(stepSelectors.fields.qualifyLength, String(config.qualifyingLength));
}
// Fill race length if provided
if (config.raceLength && stepSelectors?.fields?.raceLength) {
await this.fillFormField(stepSelectors.fields.raceLength, String(config.raceLength));
}
// Fill warmup length if provided
if (config.warmupLength && stepSelectors?.fields?.warmupLength) {
await this.fillFormField(stepSelectors.fields.warmupLength, String(config.warmupLength));
}
// Click next button to proceed
if (stepSelectors?.buttons?.next) {
const clickResult = await this.clickElement(stepSelectors.buttons.next);
if (!clickResult.success) {
return { success: false, error: clickResult.error, metadata: { step: 'TIME_LIMITS', action: 'next' } };
}
}
return { success: true, metadata: { step: 'TIME_LIMITS' } };
}
private async executeSetCarsStep(stepSelectors: ReturnType<typeof getStepSelectors>): Promise<AutomationResult> {
// This step shows the car selection overview
// Actual car addition happens in step 9 (ADD_CAR modal)
// Click next button to proceed to track selection
if (stepSelectors?.buttons?.next) {
const clickResult = await this.clickElement(stepSelectors.buttons.next);
if (!clickResult.success) {
return { success: false, error: clickResult.error, metadata: { step: 'SET_CARS', action: 'next' } };
}
}
return { success: true, metadata: { step: 'SET_CARS' } };
}
private async executeAddCarStep(
stepSelectors: ReturnType<typeof getStepSelectors>,
config: Record<string, unknown>
): Promise<AutomationResult> {
// Add car is a modal step
const carIds = config.carIds as string[] | undefined;
if (carIds && carIds.length > 0 && stepSelectors?.modal) {
// Click add car button to open modal
const step8Selectors = getStepSelectors(8);
if (step8Selectors?.buttons?.addCar) {
const clickResult = await this.clickElement(step8Selectors.buttons.addCar);
if (!clickResult.success) {
return { success: false, error: clickResult.error, metadata: { step: 'ADD_CAR', action: 'openModal' } };
}
// Wait for modal to appear
await this.waitForElement(stepSelectors.modal.container, this.config.defaultTimeout);
// Search for car would require typing in search field
// For each car, we would search and select
// For now, this is a placeholder for more complex car selection logic
// Close modal after selection (or if no action needed)
if (stepSelectors.modal.closeButton) {
await this.clickElement(stepSelectors.modal.closeButton);
}
}
}
return { success: true, metadata: { step: 'ADD_CAR', isModalStep: true, carCount: carIds?.length ?? 0 } };
}
private async executeSetCarClassesStep(
stepSelectors: ReturnType<typeof getStepSelectors>,
config: Record<string, unknown>
): Promise<AutomationResult> {
// Car classes configuration - usually auto-configured based on selected cars
// Click next button to proceed
if (stepSelectors?.buttons?.next) {
const clickResult = await this.clickElement(stepSelectors.buttons.next);
if (!clickResult.success) {
return { success: false, error: clickResult.error, metadata: { step: 'SET_CAR_CLASSES', action: 'next' } };
}
}
return { success: true, metadata: { step: 'SET_CAR_CLASSES' } };
}
private async executeSetTrackStep(stepSelectors: ReturnType<typeof getStepSelectors>): Promise<AutomationResult> {
// This step shows the track selection overview
// Actual track selection happens in step 12 (ADD_TRACK modal)
// Click next button to proceed
if (stepSelectors?.buttons?.next) {
const clickResult = await this.clickElement(stepSelectors.buttons.next);
if (!clickResult.success) {
return { success: false, error: clickResult.error, metadata: { step: 'SET_TRACK', action: 'next' } };
}
}
return { success: true, metadata: { step: 'SET_TRACK' } };
}
private async executeAddTrackStep(
stepSelectors: ReturnType<typeof getStepSelectors>,
config: Record<string, unknown>
): Promise<AutomationResult> {
// Add track is a modal step
const trackId = config.trackId as string | undefined;
if (trackId && stepSelectors?.modal) {
// Click add track button to open modal
const step11Selectors = getStepSelectors(11);
if (step11Selectors?.buttons?.addTrack) {
const clickResult = await this.clickElement(step11Selectors.buttons.addTrack);
if (!clickResult.success) {
return { success: false, error: clickResult.error, metadata: { step: 'ADD_TRACK', action: 'openModal' } };
}
// Wait for modal to appear
await this.waitForElement(stepSelectors.modal.container, this.config.defaultTimeout);
// Search for track would require typing in search field
// For now, this is a placeholder for more complex track selection logic
// Close modal after selection (or if no action needed)
if (stepSelectors.modal.closeButton) {
await this.clickElement(stepSelectors.modal.closeButton);
}
}
}
return { success: true, metadata: { step: 'ADD_TRACK', isModalStep: true, trackId } };
}
private async executeTrackOptionsStep(
stepSelectors: ReturnType<typeof getStepSelectors>,
config: Record<string, unknown>
): Promise<AutomationResult> {
// Track options like configuration, pit stalls etc.
// Accept defaults for now
// Click next button to proceed
if (stepSelectors?.buttons?.next) {
const clickResult = await this.clickElement(stepSelectors.buttons.next);
if (!clickResult.success) {
return { success: false, error: clickResult.error, metadata: { step: 'TRACK_OPTIONS', action: 'next' } };
}
}
return { success: true, metadata: { step: 'TRACK_OPTIONS' } };
}
private async executeTimeOfDayStep(
stepSelectors: ReturnType<typeof getStepSelectors>,
config: Record<string, unknown>
): Promise<AutomationResult> {
// Time of day configuration
// Accept defaults for now - time sliders are complex to interact with
// Click next button to proceed
if (stepSelectors?.buttons?.next) {
const clickResult = await this.clickElement(stepSelectors.buttons.next);
if (!clickResult.success) {
return { success: false, error: clickResult.error, metadata: { step: 'TIME_OF_DAY', action: 'next' } };
}
}
return { success: true, metadata: { step: 'TIME_OF_DAY' } };
}
private async executeWeatherStep(
stepSelectors: ReturnType<typeof getStepSelectors>,
config: Record<string, unknown>
): Promise<AutomationResult> {
// Weather configuration
// Accept defaults for now
// Click next button to proceed
if (stepSelectors?.buttons?.next) {
const clickResult = await this.clickElement(stepSelectors.buttons.next);
if (!clickResult.success) {
return { success: false, error: clickResult.error, metadata: { step: 'WEATHER', action: 'next' } };
}
}
return { success: true, metadata: { step: 'WEATHER' } };
}
private async executeRaceOptionsStep(
stepSelectors: ReturnType<typeof getStepSelectors>,
config: Record<string, unknown>
): Promise<AutomationResult> {
// Race options like max drivers, hardcore incidents, etc.
// Fill max drivers if provided
if (config.maxDrivers && stepSelectors?.fields?.maxDrivers) {
await this.fillFormField(stepSelectors.fields.maxDrivers, String(config.maxDrivers));
}
// Click next button to proceed
if (stepSelectors?.buttons?.next) {
const clickResult = await this.clickElement(stepSelectors.buttons.next);
if (!clickResult.success) {
return { success: false, error: clickResult.error, metadata: { step: 'RACE_OPTIONS', action: 'next' } };
}
}
return { success: true, metadata: { step: 'RACE_OPTIONS' } };
}
private async executeTeamDrivingStep(
stepSelectors: ReturnType<typeof getStepSelectors>,
config: Record<string, unknown>
): Promise<AutomationResult> {
// Team driving configuration
// Accept defaults for now (usually disabled)
// Click next button to proceed
if (stepSelectors?.buttons?.next) {
const clickResult = await this.clickElement(stepSelectors.buttons.next);
if (!clickResult.success) {
return { success: false, error: clickResult.error, metadata: { step: 'TEAM_DRIVING', action: 'next' } };
}
}
return { success: true, metadata: { step: 'TEAM_DRIVING' } };
}
private async executeTrackConditionsStep(
stepSelectors: ReturnType<typeof getStepSelectors>,
config: Record<string, unknown>
): Promise<AutomationResult> {
// FINAL STEP - SAFETY STOP
// We fill track conditions but DO NOT click checkout button
// Track state selection would require dropdown interaction
// For now, accept defaults
return {
success: true,
metadata: {
step: 'TRACK_CONDITIONS',
safetyStop: true,
message: 'Automation stopped at final step. User must review configuration and click checkout manually.',
checkoutButtonSelector: IRacingSelectorMap.common.checkoutButton
}
};
}
}

View File

@@ -0,0 +1,102 @@
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';
export class MockAutomationEngineAdapter implements IAutomationEngine {
private automationInterval: NodeJS.Timeout | 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 {
this.automationInterval = setInterval(async () => {
try {
const sessions = await this.sessionRepository.findAll();
const session = sessions[0];
if (!session || !session.state.isInProgress()) {
if (this.automationInterval) {
clearInterval(this.automationInterval);
this.automationInterval = null;
}
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 Record<string, unknown>);
if (!result.success) {
console.error(`Step ${currentStep.value} (${getStepName(currentStep.value)}) failed:`, result.error);
// Continue anyway for now - in production we might want to pause or retry
}
} else {
// Fallback for adapters without executeStep (e.g., MockBrowserAutomationAdapter)
await this.browserAutomation.navigateToPage(`step-${currentStep.value}`);
}
// Transition to next step if not final
if (!currentStep.isFinalStep()) {
session.transitionToStep(currentStep.next());
await this.sessionRepository.update(session);
} else {
// Stop at step 18
if (this.automationInterval) {
clearInterval(this.automationInterval);
this.automationInterval = null;
}
}
} catch (error) {
console.error('Automation error:', error);
if (this.automationInterval) {
clearInterval(this.automationInterval);
this.automationInterval = null;
}
}
}, 500); // Execute each step every 500ms
}
public stopAutomation(): void {
if (this.automationInterval) {
clearInterval(this.automationInterval);
this.automationInterval = null;
}
}
}

View File

@@ -0,0 +1,158 @@
import { StepId } from '../../../packages/domain/value-objects/StepId';
import { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
import { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation';
import {
NavigationResult,
FormFillResult,
ClickResult,
WaitResult,
ModalResult,
} from '../../../packages/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<void> {
this.connected = 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: HostedSessionConfig): Promise<StepExecutionResult> {
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,
stepId: stepId.value,
wasModalStep: stepId.isModalStep(),
shouldStop: stepId.isFinalStep(),
executionTime,
metrics: {
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,204 @@
import { mouse, keyboard, screen, Point, Key } from '@nut-tree-fork/nut-js';
import { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation';
import {
AutomationResult,
NavigationResult,
FormFillResult,
ClickResult,
WaitResult,
ModalResult,
} from '../../../packages/application/ports/AutomationResults';
import { StepId } from '../../../packages/domain/value-objects/StepId';
export interface NutJsConfig {
mouseSpeed?: number;
keyboardDelay?: number;
screenResolution?: { width: number; height: number };
defaultTimeout?: number;
}
export class NutJsAutomationAdapter implements IBrowserAutomation {
private config: Required<NutJsConfig>;
private connected: boolean = false;
constructor(config: NutJsConfig = {}) {
this.config = {
mouseSpeed: config.mouseSpeed ?? 1000,
keyboardDelay: config.keyboardDelay ?? 50,
screenResolution: config.screenResolution ?? { width: 1920, height: 1080 },
defaultTimeout: config.defaultTimeout ?? 30000,
};
mouse.config.mouseSpeed = this.config.mouseSpeed;
keyboard.config.autoDelayMs = this.config.keyboardDelay;
}
async connect(): Promise<AutomationResult> {
try {
await screen.width();
await screen.height();
this.connected = true;
return { success: true };
} catch (error) {
return { success: false, error: `Screen access failed: ${error}` };
}
}
async navigateToPage(url: string): Promise<NavigationResult> {
const startTime = Date.now();
try {
const isMac = process.platform === 'darwin';
if (isMac) {
await keyboard.pressKey(Key.LeftSuper, Key.L);
await keyboard.releaseKey(Key.LeftSuper, Key.L);
} else {
await keyboard.pressKey(Key.LeftControl, Key.L);
await keyboard.releaseKey(Key.LeftControl, Key.L);
}
await this.delay(100);
await keyboard.type(url);
await keyboard.pressKey(Key.Enter);
await keyboard.releaseKey(Key.Enter);
await this.delay(2000);
return { success: true, url, loadTime: Date.now() - startTime };
} catch (error) {
return { success: false, url, loadTime: 0, error: String(error) };
}
}
async fillFormField(fieldName: string, value: string): Promise<FormFillResult> {
try {
const isMac = process.platform === 'darwin';
if (isMac) {
await keyboard.pressKey(Key.LeftSuper, Key.A);
await keyboard.releaseKey(Key.LeftSuper, Key.A);
} else {
await keyboard.pressKey(Key.LeftControl, Key.A);
await keyboard.releaseKey(Key.LeftControl, Key.A);
}
await this.delay(50);
await keyboard.type(value);
return { success: true, fieldName, valueSet: value };
} catch (error) {
return { success: false, fieldName, valueSet: '', error: String(error) };
}
}
async clickElement(target: string): Promise<ClickResult> {
try {
const point = this.parseTarget(target);
await mouse.move([point]);
await mouse.leftClick();
return { success: true, target };
} catch (error) {
return { success: false, target, error: String(error) };
}
}
async waitForElement(target: string, maxWaitMs?: number): Promise<WaitResult> {
const startTime = Date.now();
const timeout = maxWaitMs ?? this.config.defaultTimeout;
await this.delay(Math.min(1000, timeout));
return {
success: true,
target,
waitedMs: Date.now() - startTime,
found: true,
};
}
async handleModal(stepId: StepId, action: string): Promise<ModalResult> {
try {
if (action === 'confirm') {
await keyboard.pressKey(Key.Enter);
await keyboard.releaseKey(Key.Enter);
} else if (action === 'cancel') {
await keyboard.pressKey(Key.Escape);
await keyboard.releaseKey(Key.Escape);
}
return { success: true, stepId: stepId.value, action };
} catch (error) {
return { success: false, stepId: stepId.value, action, error: String(error) };
}
}
async disconnect(): Promise<void> {
this.connected = false;
}
isConnected(): boolean {
return this.connected;
}
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
const stepNumber = stepId.value;
try {
switch (stepNumber) {
case 1:
return {
success: true,
metadata: {
skipped: true,
reason: 'User pre-authenticated',
step: 'LOGIN',
},
};
case 18:
return {
success: true,
metadata: {
step: 'TRACK_CONDITIONS',
safetyStop: true,
message: 'Automation stopped at final step. User must review configuration and click checkout manually.',
},
};
default:
return {
success: true,
metadata: {
step: `STEP_${stepNumber}`,
message: `Step ${stepNumber} executed via OS-level automation`,
config,
},
};
}
} catch (error) {
return {
success: false,
error: String(error),
metadata: { step: `STEP_${stepNumber}` },
};
}
}
private parseTarget(target: string): Point {
if (target.includes(',')) {
const [x, y] = target.split(',').map(Number);
return new Point(x, y);
}
return new Point(
Math.floor(this.config.screenResolution.width / 2),
Math.floor(this.config.screenResolution.height / 2)
);
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,23 @@
/**
* Automation adapters for browser automation.
*
* Exports:
* - MockBrowserAutomationAdapter: Mock adapter for testing
* - BrowserDevToolsAdapter: Real browser automation via Chrome DevTools Protocol
* - IRacingSelectorMap: CSS selectors for iRacing UI elements
*/
// Adapters
export { MockBrowserAutomationAdapter } from './MockBrowserAutomationAdapter';
export { BrowserDevToolsAdapter, DevToolsConfig } from './BrowserDevToolsAdapter';
export { NutJsAutomationAdapter, NutJsConfig } from './NutJsAutomationAdapter';
// Selector map and utilities
export {
IRacingSelectorMap,
IRacingSelectorMapType,
StepSelectors,
getStepSelectors,
getStepName,
isModalStep,
} from './selectors/IRacingSelectorMap';

View File

@@ -0,0 +1,399 @@
/**
* 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

@@ -0,0 +1,102 @@
/**
* Automation configuration module for environment-based adapter selection.
*
* This module provides configuration types and loaders for the automation system,
* allowing switching between different adapters based on environment variables.
*/
export type AutomationMode = 'dev' | 'production' | 'mock';
export interface AutomationEnvironmentConfig {
mode: AutomationMode;
/** Dev mode configuration (Browser DevTools) */
devTools?: {
browserWSEndpoint?: string;
debuggingPort?: number;
};
/** Production mode configuration (nut.js) */
nutJs?: {
mouseSpeed?: number;
keyboardDelay?: number;
windowTitle?: string;
templatePath?: string;
confidence?: number;
};
/** Default timeout for automation operations in milliseconds */
defaultTimeout?: number;
/** Number of retry attempts for failed operations */
retryAttempts?: number;
/** Whether to capture screenshots on error */
screenshotOnError?: boolean;
}
/**
* Load automation configuration from environment variables.
*
* Environment variables:
* - AUTOMATION_MODE: 'dev' | 'production' | 'mock' (default: '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)
*
* @returns AutomationEnvironmentConfig with parsed environment values
*/
export function loadAutomationConfig(): AutomationEnvironmentConfig {
const modeEnv = process.env.AUTOMATION_MODE;
const mode: AutomationMode = isValidAutomationMode(modeEnv) ? modeEnv : 'mock';
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),
windowTitle: process.env.IRACING_WINDOW_TITLE || 'iRacing',
templatePath: process.env.TEMPLATE_PATH || './resources/templates',
confidence: parseFloatSafe(process.env.OCR_CONFIDENCE, 0.9),
},
defaultTimeout: parseIntSafe(process.env.AUTOMATION_TIMEOUT, 30000),
retryAttempts: parseIntSafe(process.env.RETRY_ATTEMPTS, 3),
screenshotOnError: process.env.SCREENSHOT_ON_ERROR !== 'false',
};
}
/**
* Type guard to validate automation mode string.
*/
function isValidAutomationMode(value: string | undefined): value is AutomationMode {
return value === 'dev' || value === 'production' || value === 'mock';
}
/**
* Safely parse an integer with a default fallback.
*/
function parseIntSafe(value: string | undefined, defaultValue: number): number {
if (value === undefined || value === '') {
return defaultValue;
}
const parsed = parseInt(value, 10);
return isNaN(parsed) ? defaultValue : parsed;
}
/**
* Safely parse a float with a default fallback.
*/
function parseFloatSafe(value: string | undefined, defaultValue: number): number {
if (value === undefined || value === '') {
return defaultValue;
}
const parsed = parseFloat(value);
return isNaN(parsed) ? defaultValue : parsed;
}

View File

@@ -0,0 +1,9 @@
/**
* Configuration module exports for infrastructure layer.
*/
export {
AutomationMode,
AutomationEnvironmentConfig,
loadAutomationConfig,
} from './AutomationConfig';

View File

@@ -0,0 +1,36 @@
import { AutomationSession } from '../../packages/domain/entities/AutomationSession';
import { SessionStateValue } from '../../packages/domain/value-objects/SessionState';
import { ISessionRepository } from '../../packages/application/ports/ISessionRepository';
export class InMemorySessionRepository implements ISessionRepository {
private sessions: Map<string, AutomationSession> = new Map();
async save(session: AutomationSession): Promise<void> {
this.sessions.set(session.id, session);
}
async findById(id: string): Promise<AutomationSession | null> {
return this.sessions.get(id) || null;
}
async update(session: AutomationSession): Promise<void> {
if (!this.sessions.has(session.id)) {
throw new Error('Session not found');
}
this.sessions.set(session.id, session);
}
async delete(id: string): Promise<void> {
this.sessions.delete(id);
}
async findAll(): Promise<AutomationSession[]> {
return Array.from(this.sessions.values());
}
async findByState(state: SessionStateValue): Promise<AutomationSession[]> {
return Array.from(this.sessions.values()).filter(
session => session.state.value === state
);
}
}

View File

@@ -0,0 +1,62 @@
export class Result<T, E = Error> {
private constructor(
private readonly _value?: T,
private readonly _error?: E,
private readonly _isSuccess: boolean = true
) {}
static ok<T, E = Error>(value: T): Result<T, E> {
return new Result<T, E>(value, undefined, true);
}
static err<T, E = Error>(error: E): Result<T, E> {
return new Result<T, E>(undefined, error, false);
}
isOk(): boolean {
return this._isSuccess;
}
isErr(): boolean {
return !this._isSuccess;
}
unwrap(): T {
if (!this._isSuccess) {
throw new Error('Called unwrap on an error result');
}
return this._value!;
}
unwrapOr(defaultValue: T): T {
return this._isSuccess ? this._value! : defaultValue;
}
unwrapErr(): E {
if (this._isSuccess) {
throw new Error('Called unwrapErr on a success result');
}
return this._error!;
}
map<U>(fn: (value: T) => U): Result<U, E> {
if (this._isSuccess) {
return Result.ok(fn(this._value!));
}
return Result.err(this._error!);
}
mapErr<F>(fn: (error: E) => F): Result<T, F> {
if (!this._isSuccess) {
return Result.err(fn(this._error!));
}
return Result.ok(this._value!);
}
andThen<U>(fn: (value: T) => Result<U, E>): Result<U, E> {
if (this._isSuccess) {
return fn(this._value!);
}
return Result.err(this._error!);
}
}