This commit is contained in:
2025-12-04 11:54:42 +01:00
parent 9d5caa87f3
commit b7d5551ea7
223 changed files with 5473 additions and 885 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,76 @@
import { AuthenticationState } from '@gridpilot/automation/domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from '@gridpilot/automation/domain/value-objects/BrowserAuthenticationState';
import { Result } from '../../shared/result/Result';
/**
* Port for authentication services implementing zero-knowledge login.
*
* GridPilot never sees, stores, or transmits user credentials.
* Authentication is handled by opening a visible browser window where
* the user logs in directly with iRacing. GridPilot only observes
* URL changes to detect successful authentication.
*/
export interface IAuthenticationService {
/**
* Check if user has a valid session without prompting login.
* Navigates to a protected iRacing page and checks for login redirects.
*
* @returns Result containing the current authentication state
*/
checkSession(): Promise<Result<AuthenticationState>>;
/**
* Open browser for user to login manually.
* The browser window is visible so user can verify they're on the real iRacing site.
* GridPilot waits for URL change indicating successful login.
*
* @returns Result indicating success (login complete) or failure (cancelled/timeout)
*/
initiateLogin(): Promise<Result<void>>;
/**
* Clear the persistent session (logout).
* Removes stored browser context and cookies.
*
* @returns Result indicating success or failure
*/
clearSession(): Promise<Result<void>>;
/**
* Get current authentication state.
* Returns cached state without making network requests.
*
* @returns The current AuthenticationState
*/
getState(): AuthenticationState;
/**
* Validate session with server-side check.
* Makes a lightweight HTTP request to verify cookies are still valid on the server.
*
* @returns Result containing true if server confirms validity, false otherwise
*/
validateServerSide(): Promise<Result<boolean>>;
/**
* Refresh session state from cookie store.
* Re-reads cookies and updates internal state without server validation.
*
* @returns Result indicating success or failure
*/
refreshSession(): Promise<Result<void>>;
/**
* Get session expiry date.
* Returns the expiry time extracted from session cookies.
*
* @returns Result containing the expiry Date or null if no expiration
*/
getSessionExpiry(): Promise<Result<Date | null>>;
/**
* Verify browser page shows authenticated state.
* Checks page content for authentication indicators.
*/
verifyPageAuthentication(): Promise<Result<BrowserAuthenticationState>>;
}

View File

@@ -0,0 +1,13 @@
import { HostedSessionConfig } from '@gridpilot/automation/domain/entities/HostedSessionConfig';
import { StepId } from '@gridpilot/automation/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>;
stopAutomation(): void;
}

View File

@@ -0,0 +1,10 @@
export type AutomationEvent = {
actionId?: string
type: 'panel-attached'|'modal-opened'|'action-started'|'action-complete'|'action-failed'|'panel-missing'
timestamp: number
payload?: any
}
export interface IAutomationEventPublisher {
publish(event: AutomationEvent): Promise<void>
}

View File

@@ -0,0 +1,21 @@
import { Result } from '../../shared/result/Result';
import { CheckoutConfirmation } from '@gridpilot/automation/domain/value-objects/CheckoutConfirmation';
import { CheckoutPrice } from '@gridpilot/automation/domain/value-objects/CheckoutPrice';
import { CheckoutState } from '@gridpilot/automation/domain/value-objects/CheckoutState';
export interface CheckoutConfirmationRequest {
price: CheckoutPrice;
state: CheckoutState;
sessionMetadata: {
sessionName: string;
trackId: string;
carIds: string[];
};
timeoutMs: number;
}
export interface ICheckoutConfirmationPort {
requestCheckoutConfirmation(
request: CheckoutConfirmationRequest
): Promise<Result<CheckoutConfirmation>>;
}

View File

@@ -0,0 +1,14 @@
import { Result } from '../../shared/result/Result';
import { CheckoutPrice } from '@gridpilot/automation/domain/value-objects/CheckoutPrice';
import { CheckoutState } from '@gridpilot/automation/domain/value-objects/CheckoutState';
export interface CheckoutInfo {
price: CheckoutPrice | null;
state: CheckoutState;
buttonHtml: string;
}
export interface ICheckoutService {
extractCheckoutInfo(): Promise<Result<CheckoutInfo>>;
proceedWithCheckout(): Promise<Result<void>>;
}

View File

@@ -0,0 +1,35 @@
/**
* Log levels in order of severity (lowest to highest)
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
/**
* Contextual metadata attached to log entries
*/
export interface LogContext {
/** Unique session identifier for correlation */
sessionId?: string;
/** Current automation step (1-18) */
stepId?: number;
/** Step name for human readability */
stepName?: string;
/** Adapter or component name */
adapter?: string;
/** Operation duration in milliseconds */
durationMs?: number;
/** Additional arbitrary metadata */
[key: string]: unknown;
}
/**
* ILogger - Port interface for application-layer logging.
*/
export interface ILogger {
debug(message: string, context?: LogContext): void;
info(message: string, context?: LogContext): void;
warn(message: string, context?: LogContext): void;
error(message: string, error?: Error, context?: LogContext): void;
fatal(message: string, error?: Error, context?: LogContext): void;
child(context: LogContext): ILogger;
flush(): Promise<void>;
}

View File

@@ -0,0 +1,7 @@
export type OverlayAction = { id: string; label: string; meta?: Record<string, unknown>; timeoutMs?: number }
export type ActionAck = { id: string; status: 'confirmed' | 'tentative' | 'failed'; reason?: string }
export interface IOverlaySyncPort {
startAction(action: OverlayAction): Promise<ActionAck>
cancelAction(actionId: string): Promise<void>
}

View File

@@ -0,0 +1,70 @@
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
import {
NavigationResult,
FormFillResult,
ClickResult,
WaitResult,
ModalResult,
AutomationResult,
} from './AutomationResults';
/**
* Browser automation interface for Playwright-based automation.
*
* This interface defines the contract for browser automation using
* standard DOM manipulation via Playwright. All automation is done
* through browser DevTools protocol - no OS-level automation.
*/
export interface IBrowserAutomation {
/**
* Navigate to a URL.
*/
navigateToPage(url: string): Promise<NavigationResult>;
/**
* Fill a form field by name or selector.
*/
fillFormField(fieldName: string, value: string): Promise<FormFillResult>;
/**
* Click an element by selector or action name.
*/
clickElement(target: string): Promise<ClickResult>;
/**
* Wait for an element to appear.
*/
waitForElement(target: string, maxWaitMs?: number): Promise<WaitResult>;
/**
* Handle modal dialogs.
*/
handleModal(stepId: StepId, action: string): Promise<ModalResult>;
/**
* Execute a complete workflow step.
*/
executeStep?(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult>;
/**
* Initialize the browser connection.
* Returns an AutomationResult indicating success or failure.
*/
connect?(): Promise<AutomationResult>;
/**
* Clean up browser resources.
*/
disconnect?(): Promise<void>;
/**
* Check if browser is connected and ready.
*/
isConnected?(): boolean;
}
/**
* @deprecated Use IBrowserAutomation directly. IScreenAutomation was for OS-level
* automation which has been removed in favor of browser-only automation.
*/
export type IScreenAutomation = IBrowserAutomation;

View File

@@ -0,0 +1,11 @@
import { AutomationSession } from '@gridpilot/automation/domain/entities/AutomationSession';
import { SessionStateValue } from '@gridpilot/automation/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,3 @@
export interface IUserConfirmationPort {
confirm(message: string): Promise<boolean>;
}

View File

@@ -0,0 +1,124 @@
import { IOverlaySyncPort, OverlayAction, ActionAck } from '../ports/IOverlaySyncPort';
import { IAutomationEventPublisher, AutomationEvent } from '../ports/IAutomationEventPublisher';
import { IAutomationLifecycleEmitter, LifecycleCallback } from '../../infrastructure/adapters/IAutomationLifecycleEmitter';
import { ILogger } from '../ports/ILogger';
type ConstructorArgs = {
lifecycleEmitter: IAutomationLifecycleEmitter
publisher: IAutomationEventPublisher
logger: ILogger
initialPanelWaitMs?: number
maxPanelRetries?: number
backoffFactor?: number
defaultTimeoutMs?: number
}
export class OverlaySyncService implements IOverlaySyncPort {
private lifecycleEmitter: IAutomationLifecycleEmitter
private publisher: IAutomationEventPublisher
private logger: ILogger
private initialPanelWaitMs: number
private maxPanelRetries: number
private backoffFactor: number
private defaultTimeoutMs: number
constructor(args: ConstructorArgs) {
this.lifecycleEmitter = args.lifecycleEmitter
this.publisher = args.publisher
this.logger = args.logger
this.initialPanelWaitMs = args.initialPanelWaitMs ?? 500
this.maxPanelRetries = args.maxPanelRetries ?? 3
this.backoffFactor = args.backoffFactor ?? 2
this.defaultTimeoutMs = args.defaultTimeoutMs ?? 5000
}
async startAction(action: OverlayAction): Promise<ActionAck> {
const timeoutMs = action.timeoutMs ?? this.defaultTimeoutMs
const seenEvents: AutomationEvent[] = []
let settled = false
const cb: LifecycleCallback = async (ev) => {
seenEvents.push(ev)
if (ev.type === 'action-started' && ev.actionId === action.id) {
if (!settled) {
settled = true
cleanup()
resolveAck({ id: action.id, status: 'confirmed' })
}
}
}
const cleanup = () => {
try {
this.lifecycleEmitter.offLifecycle(cb)
} catch {
// ignore
}
}
let resolveAck: (ack: ActionAck) => void = () => {}
const promise = new Promise<ActionAck>((resolve) => {
resolveAck = resolve
try {
this.lifecycleEmitter.onLifecycle(cb)
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e))
this.logger?.error?.('OverlaySyncService: failed to subscribe to lifecycleEmitter', error, {
actionId: action.id,
})
}
})
try {
void this.publisher.publish({
type: 'modal-opened',
timestamp: Date.now(),
payload: { actionId: action.id, label: action.label },
actionId: action.id,
} as AutomationEvent)
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e))
this.logger?.warn?.('OverlaySyncService: publisher.publish failed', {
actionId: action.id,
error,
})
}
const timeoutPromise = new Promise<ActionAck>((res) => {
setTimeout(() => {
if (!settled) {
settled = true
cleanup()
this.logger?.info?.('OverlaySyncService: timeout waiting for confirmation', {
actionId: action.id,
timeoutMs,
})
const lastEvents = seenEvents.slice(-10)
this.logger?.debug?.('OverlaySyncService: recent lifecycle events', {
actionId: action.id,
events: lastEvents,
})
res({ id: action.id, status: 'tentative', reason: 'timeout' })
}
}, timeoutMs)
})
return Promise.race([promise, timeoutPromise])
}
async cancelAction(actionId: string): Promise<void> {
try {
await this.publisher.publish({
type: 'panel-missing',
timestamp: Date.now(),
actionId,
} as AutomationEvent)
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e))
this.logger?.warn?.('OverlaySyncService: cancelAction publish failed', {
actionId,
error,
})
}
}
}

View File

@@ -0,0 +1,98 @@
import { AuthenticationState } from '@gridpilot/automation/domain/value-objects/AuthenticationState';
import { Result } from '../../shared/result/Result';
import type { IAuthenticationService } from '../ports/IAuthenticationService';
import { SessionLifetime } from '@gridpilot/automation/domain/value-objects/SessionLifetime';
/**
* Port for optional server-side session validation.
*/
export interface ISessionValidator {
validateSession(): Promise<Result<boolean>>;
}
/**
* Use case for checking if the user has a valid iRacing session.
*
* This validates the session before automation starts, allowing
* the system to prompt for re-authentication if needed.
*
* Implements hybrid validation strategy:
* - File-based validation (fast, always executed)
* - Optional server-side validation (slow, requires network)
*/
export class CheckAuthenticationUseCase {
constructor(
private readonly authService: IAuthenticationService,
private readonly sessionValidator?: ISessionValidator
) {}
/**
* Execute the authentication check.
*
* @param options Optional configuration for validation
* @returns Result containing the current AuthenticationState
*/
async execute(options?: {
requireServerValidation?: boolean;
verifyPageContent?: boolean;
}): Promise<Result<AuthenticationState>> {
// Step 1: File-based validation (fast)
const fileResult = await this.authService.checkSession();
if (fileResult.isErr()) {
return fileResult;
}
const fileState = fileResult.unwrap();
// Step 2: Check session expiry if authenticated
if (fileState === AuthenticationState.AUTHENTICATED) {
const expiryResult = await this.authService.getSessionExpiry();
if (expiryResult.isErr()) {
// Don't fail completely if we can't get expiry, use file-based state
return Result.ok(fileState);
}
const expiry = expiryResult.unwrap();
if (expiry !== null) {
try {
const sessionLifetime = new SessionLifetime(expiry);
if (sessionLifetime.isExpired()) {
return Result.ok(AuthenticationState.EXPIRED);
}
} catch {
// Invalid expiry date, treat as expired for safety
return Result.ok(AuthenticationState.EXPIRED);
}
}
}
// Step 3: Optional page content verification
if (options?.verifyPageContent && fileState === AuthenticationState.AUTHENTICATED) {
const pageResult = await this.authService.verifyPageAuthentication();
if (pageResult.isOk()) {
const browserState = pageResult.unwrap();
// If cookies valid but page shows login UI, session is expired
if (!browserState.isFullyAuthenticated()) {
return Result.ok(AuthenticationState.EXPIRED);
}
}
// Don't block on page verification errors, continue with file-based state
}
// Step 4: Optional server-side validation
if (this.sessionValidator && fileState === AuthenticationState.AUTHENTICATED) {
const serverResult = await this.sessionValidator.validateSession();
// Don't block on server validation errors
if (serverResult.isOk()) {
const isValid = serverResult.unwrap();
if (!isValid) {
return Result.ok(AuthenticationState.EXPIRED);
}
}
}
return Result.ok(fileState);
}
}

View File

@@ -0,0 +1,21 @@
import { Result } from '../../shared/result/Result';
import type { IAuthenticationService } from '../ports/IAuthenticationService';
/**
* Use case for clearing the user's session (logout).
*
* Removes stored browser context and cookies, effectively logging
* the user out. The next automation attempt will require re-authentication.
*/
export class ClearSessionUseCase {
constructor(private readonly authService: IAuthenticationService) {}
/**
* Execute the session clearing.
*
* @returns Result indicating success or failure
*/
async execute(): Promise<Result<void>> {
return this.authService.clearSession();
}
}

View File

@@ -0,0 +1,38 @@
import { Result } from '../../shared/result/Result';
import { RaceCreationResult } from '@gridpilot/automation/domain/value-objects/RaceCreationResult';
import type { ICheckoutService } from '../ports/ICheckoutService';
export class CompleteRaceCreationUseCase {
constructor(private readonly checkoutService: ICheckoutService) {}
async execute(sessionId: string): Promise<Result<RaceCreationResult>> {
if (!sessionId || sessionId.trim() === '') {
return Result.err(new Error('Session ID is required'));
}
const infoResult = await this.checkoutService.extractCheckoutInfo();
if (infoResult.isErr()) {
return Result.err(infoResult.unwrapErr());
}
const info = infoResult.unwrap();
if (!info.price) {
return Result.err(new Error('Could not extract price from checkout page'));
}
try {
const raceCreationResult = RaceCreationResult.create({
sessionId,
price: info.price.toDisplayString(),
timestamp: new Date(),
});
return Result.ok(raceCreationResult);
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
return Result.err(err);
}
}
}

View File

@@ -0,0 +1,65 @@
import { Result } from '../../shared/result/Result';
import { ICheckoutService } from '../ports/ICheckoutService';
import { ICheckoutConfirmationPort } from '../ports/ICheckoutConfirmationPort';
import { CheckoutStateEnum } from '@gridpilot/automation/domain/value-objects/CheckoutState';
interface SessionMetadata {
sessionName: string;
trackId: string;
carIds: string[];
}
export class ConfirmCheckoutUseCase {
private static readonly DEFAULT_TIMEOUT_MS = 30000;
constructor(
private readonly checkoutService: ICheckoutService,
private readonly confirmationPort: ICheckoutConfirmationPort
) {}
async execute(sessionMetadata?: SessionMetadata): Promise<Result<void>> {
const infoResult = await this.checkoutService.extractCheckoutInfo();
if (infoResult.isErr()) {
return Result.err(infoResult.unwrapErr());
}
const info = infoResult.unwrap();
if (info.state.getValue() === CheckoutStateEnum.INSUFFICIENT_FUNDS) {
return Result.err(new Error('Insufficient funds to complete checkout'));
}
if (!info.price) {
return Result.err(new Error('Could not extract price from checkout page'));
}
// Request confirmation via port with full checkout context
const confirmationResult = await this.confirmationPort.requestCheckoutConfirmation({
price: info.price,
state: info.state,
sessionMetadata: sessionMetadata || {
sessionName: 'Unknown Session',
trackId: 'unknown',
carIds: [],
},
timeoutMs: ConfirmCheckoutUseCase.DEFAULT_TIMEOUT_MS,
});
if (confirmationResult.isErr()) {
return Result.err(confirmationResult.unwrapErr());
}
const confirmation = confirmationResult.unwrap();
if (confirmation.isCancelled()) {
return Result.err(new Error('Checkout cancelled by user'));
}
if (confirmation.isTimeout()) {
return Result.err(new Error('Checkout confirmation timeout'));
}
return await this.checkoutService.proceedWithCheckout();
}
}

View File

@@ -0,0 +1,23 @@
import { Result } from '../../shared/result/Result';
import type { IAuthenticationService } from '../ports/IAuthenticationService';
/**
* Use case for initiating the manual login flow.
*
* Opens a visible browser window where the user can log into iRacing directly.
* GridPilot never sees the credentials - it only waits for the URL to change
* indicating successful login.
*/
export class InitiateLoginUseCase {
constructor(private readonly authService: IAuthenticationService) {}
/**
* Execute the login flow.
* Opens browser and waits for user to complete manual login.
*
* @returns Result indicating success (login complete) or failure (cancelled/timeout)
*/
async execute(): Promise<Result<void>> {
return this.authService.initiateLogin();
}
}

View File

@@ -0,0 +1,44 @@
import { AutomationSession } from '@gridpilot/automation/domain/entities/AutomationSession';
import { HostedSessionConfig } from '@gridpilot/automation/domain/entities/HostedSessionConfig';
import { IAutomationEngine } from '../ports/IAutomationEngine';
import type { IBrowserAutomation } from '../ports/IScreenAutomation';
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,30 @@
import { IAuthenticationService } from '../ports/IAuthenticationService';
import { Result } from '../../shared/result/Result';
import { BrowserAuthenticationState } from '@gridpilot/automation/domain/value-objects/BrowserAuthenticationState';
/**
* Use case for verifying browser shows authenticated page state.
* Combines cookie validation with page content verification.
*/
export class VerifyAuthenticatedPageUseCase {
constructor(
private readonly authService: IAuthenticationService
) {}
async execute(): Promise<Result<BrowserAuthenticationState>> {
try {
const result = await this.authService.verifyPageAuthentication();
if (result.isErr()) {
const error = result.error ?? new Error('Page verification failed');
return Result.err<BrowserAuthenticationState>(error);
}
const browserState = result.unwrap();
return Result.ok<BrowserAuthenticationState>(browserState);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Result.err<BrowserAuthenticationState>(new Error(`Page verification failed: ${message}`));
}
}
}

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,28 @@
export interface HostedSessionConfig {
sessionName: string;
trackId: string;
carIds: string[];
// Optional fields for extended configuration.
serverName?: string;
password?: string;
adminPassword?: string;
maxDrivers?: number;
/** Search term for car selection (alternative to carIds) */
carSearch?: string;
/** Search term for track selection (alternative to trackId) */
trackSearch?: string;
weatherType?: 'static' | 'dynamic';
timeOfDay?: 'morning' | 'afternoon' | 'evening' | 'night';
sessionDuration?: number;
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,224 @@
import { Result } from '../shared/Result';
/**
* Configuration for page state validation.
* Defines expected and forbidden elements on the current page.
*/
export interface PageStateValidation {
/** Expected wizard step name (e.g., 'cars', 'track') */
expectedStep: string;
/** Selectors that MUST be present on the page */
requiredSelectors: string[];
/** Selectors that MUST NOT be present on the page */
forbiddenSelectors?: string[];
}
/**
* Result of page state validation.
*/
export interface PageStateValidationResult {
isValid: boolean;
message: string;
expectedStep: string;
missingSelectors?: string[];
unexpectedSelectors?: string[];
}
/**
* Domain service for validating page state during wizard navigation.
*
* Purpose: Prevent navigation bugs by ensuring each step executes on the correct page.
*
* Clean Architecture: This is pure domain logic with no infrastructure dependencies.
* It validates state based on selector presence/absence without knowing HOW to check them.
*/
export class PageStateValidator {
/**
* Validate that the page state matches expected conditions.
*
* @param actualState Function that checks if selectors exist on the page
* @param validation Expected page state configuration
* @returns Result with validation outcome
*/
validateState(
actualState: (selector: string) => boolean,
validation: PageStateValidation
): Result<PageStateValidationResult, Error> {
try {
const { expectedStep, requiredSelectors, forbiddenSelectors = [] } = validation;
// Check required selectors are present
const missingSelectors = requiredSelectors.filter(selector => !actualState(selector));
if (missingSelectors.length > 0) {
const result: PageStateValidationResult = {
isValid: false,
message: `Page state mismatch: Expected to be on "${expectedStep}" page but missing required elements`,
expectedStep,
missingSelectors
};
return Result.ok(result);
}
// Check forbidden selectors are absent
const unexpectedSelectors = forbiddenSelectors.filter(selector => actualState(selector));
if (unexpectedSelectors.length > 0) {
const result: PageStateValidationResult = {
isValid: false,
message: `Page state mismatch: Found unexpected elements on "${expectedStep}" page`,
expectedStep,
unexpectedSelectors
};
return Result.ok(result);
}
// All checks passed
const result: PageStateValidationResult = {
isValid: true,
message: `Page state valid for "${expectedStep}"`,
expectedStep
};
return Result.ok(result);
} catch (error) {
return Result.err(
error instanceof Error
? error
: new Error(`Page state validation failed: ${String(error)}`)
);
}
}
/**
* Enhanced validation that tries multiple selector strategies for real iRacing HTML.
* This handles the mismatch between test expectations (data-indicator attributes)
* and real HTML structure (Chakra UI components).
*
* @param actualState Function that checks if selectors exist on the page
* @param validation Expected page state configuration
* @param realMode Whether we're in real mode (using real HTML dumps) or mock mode
* @returns Result with validation outcome
*/
validateStateEnhanced(
actualState: (selector: string) => boolean,
validation: PageStateValidation,
realMode: boolean = false
): Result<PageStateValidationResult, Error> {
try {
const { expectedStep, requiredSelectors, forbiddenSelectors = [] } = validation;
// In real mode, try to match the actual HTML structure with fallbacks
let selectorsToCheck = [...requiredSelectors];
if (realMode) {
// Add fallback selectors for real iRacing HTML (Chakra UI structure)
const fallbackMap: Record<string, string[]> = {
cars: [
'#set-cars',
'[id*="cars"]',
'.wizard-step[id*="cars"]',
'.cars-panel',
// Real iRacing fallbacks - use step container IDs
'[data-testid*="set-cars"]',
'.chakra-stack:has([data-testid*="cars"])',
],
track: [
'#set-track',
'[id*="track"]',
'.wizard-step[id*="track"]',
'.track-panel',
// Real iRacing fallbacks
'[data-testid*="set-track"]',
'.chakra-stack:has([data-testid*="track"])',
],
'add-car': [
'a.btn:has-text("Add a Car")',
'.btn:has-text("Add a Car")',
'[data-testid*="add-car"]',
// Real iRacing button selectors
'a.btn.btn-primary.btn-block.btn-sm:has-text("Add a Car")',
],
};
// For each required selector, add fallbacks
const enhancedSelectors: string[] = [];
for (const selector of requiredSelectors) {
enhancedSelectors.push(selector);
// Add step-specific fallbacks
const lowerStep = expectedStep.toLowerCase();
if (fallbackMap[lowerStep]) {
enhancedSelectors.push(...fallbackMap[lowerStep]);
}
// Generic Chakra UI fallbacks for wizard steps
if (selector.includes('data-indicator')) {
enhancedSelectors.push(
`[id*="${expectedStep}"]`,
`[data-testid*="${expectedStep}"]`,
`.wizard-step:has([data-testid*="${expectedStep}"])`,
);
}
}
selectorsToCheck = enhancedSelectors;
}
// Check required selectors are present (with fallbacks for real mode)
const missingSelectors = requiredSelectors.filter(selector => {
if (realMode) {
const relatedSelectors = selectorsToCheck.filter(s =>
s.includes(expectedStep) ||
s.includes(
selector
.replace(/[\[\]"']/g, '')
.replace('data-indicator=', ''),
),
);
if (relatedSelectors.length === 0) {
return !actualState(selector);
}
return !relatedSelectors.some(s => actualState(s));
}
return !actualState(selector);
});
if (missingSelectors.length > 0) {
const result: PageStateValidationResult = {
isValid: false,
message: `Page state mismatch: Expected to be on "${expectedStep}" page but missing required elements`,
expectedStep,
missingSelectors
};
return Result.ok(result);
}
// Check forbidden selectors are absent
const unexpectedSelectors = forbiddenSelectors.filter(selector => actualState(selector));
if (unexpectedSelectors.length > 0) {
const result: PageStateValidationResult = {
isValid: false,
message: `Page state mismatch: Found unexpected elements on "${expectedStep}" page`,
expectedStep,
unexpectedSelectors
};
return Result.ok(result);
}
// All checks passed
const result: PageStateValidationResult = {
isValid: true,
message: `Page state valid for "${expectedStep}"`,
expectedStep
};
return Result.ok(result);
} catch (error) {
return Result.err(
error instanceof Error
? error
: new Error(`Page state validation failed: ${String(error)}`)
);
}
}
}

View File

@@ -0,0 +1,80 @@
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: '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,78 @@
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!);
}
/**
* Direct access to the value (for testing convenience).
* Prefer using unwrap() in production code.
*/
get value(): T | undefined {
return this._value;
}
/**
* Direct access to the error (for testing convenience).
* Prefer using unwrapErr() in production code.
*/
get error(): E | undefined {
return this._error;
}
}

View File

@@ -0,0 +1,18 @@
/**
* Value object representing the user's authentication state with iRacing.
*
* This is used to track whether the user has a valid session for automation
* without GridPilot ever seeing or storing credentials (zero-knowledge design).
*/
export const AuthenticationState = {
/** Authentication status has not yet been checked */
UNKNOWN: 'UNKNOWN',
/** Valid session exists and is ready for automation */
AUTHENTICATED: 'AUTHENTICATED',
/** Session was valid but has expired, re-authentication required */
EXPIRED: 'EXPIRED',
/** User explicitly logged out, clearing the session */
LOGGED_OUT: 'LOGGED_OUT',
} as const;
export type AuthenticationState = typeof AuthenticationState[keyof typeof AuthenticationState];

View File

@@ -0,0 +1,39 @@
import { AuthenticationState } from './AuthenticationState';
export class BrowserAuthenticationState {
private readonly cookiesValid: boolean;
private readonly pageAuthenticated: boolean;
constructor(cookiesValid: boolean, pageAuthenticated: boolean) {
this.cookiesValid = cookiesValid;
this.pageAuthenticated = pageAuthenticated;
}
isFullyAuthenticated(): boolean {
return this.cookiesValid && this.pageAuthenticated;
}
getAuthenticationState(): AuthenticationState {
if (!this.cookiesValid) {
return AuthenticationState.UNKNOWN;
}
if (!this.pageAuthenticated) {
return AuthenticationState.EXPIRED;
}
return AuthenticationState.AUTHENTICATED;
}
requiresReauthentication(): boolean {
return !this.isFullyAuthenticated();
}
getCookieValidity(): boolean {
return this.cookiesValid;
}
getPageAuthenticationStatus(): boolean {
return this.pageAuthenticated;
}
}

View File

@@ -0,0 +1,42 @@
export type CheckoutConfirmationDecision = 'confirmed' | 'cancelled' | 'timeout';
const VALID_DECISIONS: CheckoutConfirmationDecision[] = [
'confirmed',
'cancelled',
'timeout',
];
export class CheckoutConfirmation {
private readonly _value: CheckoutConfirmationDecision;
private constructor(value: CheckoutConfirmationDecision) {
this._value = value;
}
static create(value: CheckoutConfirmationDecision): CheckoutConfirmation {
if (!VALID_DECISIONS.includes(value)) {
throw new Error('Invalid checkout confirmation decision');
}
return new CheckoutConfirmation(value);
}
get value(): CheckoutConfirmationDecision {
return this._value;
}
equals(other: CheckoutConfirmation): boolean {
return this._value === other._value;
}
isConfirmed(): boolean {
return this._value === 'confirmed';
}
isCancelled(): boolean {
return this._value === 'cancelled';
}
isTimeout(): boolean {
return this._value === 'timeout';
}
}

View File

@@ -0,0 +1,57 @@
export class CheckoutPrice {
private constructor(private readonly amountUsd: number) {
if (amountUsd < 0) {
throw new Error('Price cannot be negative');
}
if (amountUsd > 10000) {
throw new Error('Price exceeds maximum of $10,000');
}
}
static fromString(priceStr: string): CheckoutPrice {
const trimmed = priceStr.trim();
if (!trimmed.startsWith('$')) {
throw new Error('Invalid price format: missing dollar sign');
}
const dollarSignCount = (trimmed.match(/\$/g) || []).length;
if (dollarSignCount > 1) {
throw new Error('Invalid price format: multiple dollar signs');
}
const numericPart = trimmed.substring(1).replace(/,/g, '');
if (numericPart === '') {
throw new Error('Invalid price format: no numeric value');
}
const amount = parseFloat(numericPart);
if (isNaN(amount)) {
throw new Error('Invalid price format: not a valid number');
}
return new CheckoutPrice(amount);
}
/**
* Factory for a neutral/zero checkout price.
* Used when no explicit price can be extracted from the DOM.
*/
static zero(): CheckoutPrice {
return new CheckoutPrice(0);
}
toDisplayString(): string {
return `$${this.amountUsd.toFixed(2)}`;
}
getAmount(): number {
return this.amountUsd;
}
isZero(): boolean {
return this.amountUsd < 0.001;
}
}

View File

@@ -0,0 +1,51 @@
export enum CheckoutStateEnum {
READY = 'READY',
INSUFFICIENT_FUNDS = 'INSUFFICIENT_FUNDS',
UNKNOWN = 'UNKNOWN'
}
export class CheckoutState {
private constructor(private readonly state: CheckoutStateEnum) {}
static ready(): CheckoutState {
return new CheckoutState(CheckoutStateEnum.READY);
}
static insufficientFunds(): CheckoutState {
return new CheckoutState(CheckoutStateEnum.INSUFFICIENT_FUNDS);
}
static unknown(): CheckoutState {
return new CheckoutState(CheckoutStateEnum.UNKNOWN);
}
static fromButtonClasses(classes: string): CheckoutState {
const normalized = classes.toLowerCase().trim();
if (normalized.includes('btn-success')) {
return CheckoutState.ready();
}
if (normalized.includes('btn')) {
return CheckoutState.insufficientFunds();
}
return CheckoutState.unknown();
}
isReady(): boolean {
return this.state === CheckoutStateEnum.READY;
}
hasInsufficientFunds(): boolean {
return this.state === CheckoutStateEnum.INSUFFICIENT_FUNDS;
}
isUnknown(): boolean {
return this.state === CheckoutStateEnum.UNKNOWN;
}
getValue(): CheckoutStateEnum {
return this.state;
}
}

View File

@@ -0,0 +1,104 @@
interface Cookie {
name: string;
value: string;
domain: string;
path: string;
secure?: boolean;
httpOnly?: boolean;
sameSite?: 'Strict' | 'Lax' | 'None';
}
export class CookieConfiguration {
private readonly cookie: Cookie;
private readonly targetUrl: URL;
constructor(cookie: Cookie, targetUrl: string) {
this.cookie = cookie;
try {
this.targetUrl = new URL(targetUrl);
} catch (error) {
throw new Error(`Invalid target URL: ${targetUrl}`);
}
this.validate();
}
private validate(): void {
if (!this.isValidDomain()) {
throw new Error(
`Domain mismatch: Cookie domain "${this.cookie.domain}" is invalid for target "${this.targetUrl.hostname}"`
);
}
if (!this.isValidPath()) {
throw new Error(
`Path not valid: Cookie path "${this.cookie.path}" is invalid for target path "${this.targetUrl.pathname}"`
);
}
}
private isValidDomain(): boolean {
const targetHost = this.targetUrl.hostname;
const cookieDomain = this.cookie.domain;
// Empty domain is invalid
if (!cookieDomain) {
return false;
}
// Exact match
if (cookieDomain === targetHost) {
return true;
}
// Wildcard domain (e.g., ".iracing.com" matches "members-ng.iracing.com")
if (cookieDomain.startsWith('.')) {
const domainWithoutDot = cookieDomain.slice(1);
return targetHost === domainWithoutDot || targetHost.endsWith('.' + domainWithoutDot);
}
// Subdomain compatibility: Allow cookies from related subdomains if they share the same base domain
// Example: "members.iracing.com" → "members-ng.iracing.com" (both share "iracing.com")
if (this.isSameBaseDomain(cookieDomain, targetHost)) {
return true;
}
return false;
}
/**
* Check if two domains share the same base domain (last 2 parts)
* @example
* isSameBaseDomain('members.iracing.com', 'members-ng.iracing.com') // true
* isSameBaseDomain('example.com', 'iracing.com') // false
*/
private isSameBaseDomain(domain1: string, domain2: string): boolean {
const parts1 = domain1.split('.');
const parts2 = domain2.split('.');
// Need at least 2 parts (domain.tld) for valid comparison
if (parts1.length < 2 || parts2.length < 2) {
return false;
}
// Compare last 2 parts (e.g., "iracing.com")
const base1 = parts1.slice(-2).join('.');
const base2 = parts2.slice(-2).join('.');
return base1 === base2;
}
private isValidPath(): boolean {
// Empty path is invalid
if (!this.cookie.path) {
return false;
}
// Path must be prefix of target pathname
return this.targetUrl.pathname.startsWith(this.cookie.path);
}
getValidatedCookie(): Cookie {
return { ...this.cookie };
}
}

View File

@@ -0,0 +1,55 @@
export interface RaceCreationResultData {
sessionId: string;
price: string;
timestamp: Date;
}
export class RaceCreationResult {
private readonly _sessionId: string;
private readonly _price: string;
private readonly _timestamp: Date;
private constructor(data: RaceCreationResultData) {
this._sessionId = data.sessionId;
this._price = data.price;
this._timestamp = data.timestamp;
}
static create(data: RaceCreationResultData): RaceCreationResult {
if (!data.sessionId || data.sessionId.trim() === '') {
throw new Error('Session ID cannot be empty');
}
if (!data.price || data.price.trim() === '') {
throw new Error('Price cannot be empty');
}
return new RaceCreationResult(data);
}
get sessionId(): string {
return this._sessionId;
}
get price(): string {
return this._price;
}
get timestamp(): Date {
return this._timestamp;
}
equals(other: RaceCreationResult): boolean {
return (
this._sessionId === other._sessionId &&
this._price === other._price &&
this._timestamp.getTime() === other._timestamp.getTime()
);
}
toJSON(): { sessionId: string; price: string; timestamp: string } {
return {
sessionId: this._sessionId,
price: this._price,
timestamp: this._timestamp.toISOString(),
};
}
}

View File

@@ -0,0 +1,86 @@
/**
* Represents a rectangular region on the screen.
* Used for targeted screen capture and element location.
*/
export interface ScreenRegion {
x: number;
y: number;
width: number;
height: number;
}
/**
* Represents a point on the screen with x,y coordinates.
*/
export interface Point {
x: number;
y: number;
}
/**
* Represents the location of a detected UI element on screen.
* Contains the center point, bounding box, and confidence score.
*/
export interface ElementLocation {
center: Point;
bounds: ScreenRegion;
confidence: number;
}
/**
* Result of login state detection via screen recognition.
*/
export interface LoginDetectionResult {
isLoggedIn: boolean;
confidence: number;
detectedIndicators: string[];
error?: string;
}
/**
* Create a ScreenRegion from coordinates.
*/
export function createScreenRegion(x: number, y: number, width: number, height: number): ScreenRegion {
return { x, y, width, height };
}
/**
* Create a Point from coordinates.
*/
export function createPoint(x: number, y: number): Point {
return { x, y };
}
/**
* Calculate the center point of a ScreenRegion.
*/
export function getRegionCenter(region: ScreenRegion): Point {
return {
x: region.x + Math.floor(region.width / 2),
y: region.y + Math.floor(region.height / 2),
};
}
/**
* Check if a point is within a screen region.
*/
export function isPointInRegion(point: Point, region: ScreenRegion): boolean {
return (
point.x >= region.x &&
point.x <= region.x + region.width &&
point.y >= region.y &&
point.y <= region.y + region.height
);
}
/**
* Check if two screen regions overlap.
*/
export function regionsOverlap(a: ScreenRegion, b: ScreenRegion): boolean {
return !(
a.x + a.width < b.x ||
b.x + b.width < a.x ||
a.y + a.height < b.y ||
b.y + b.height < a.y
);
}

View File

@@ -0,0 +1,85 @@
/**
* SessionLifetime Value Object
*
* Represents the lifetime of an authentication session with expiry tracking.
* Handles validation of session expiry dates with a configurable buffer window.
*/
export class SessionLifetime {
private readonly expiry: Date | null;
private readonly bufferMinutes: number;
constructor(expiry: Date | null, bufferMinutes: number = 5) {
if (expiry !== null) {
if (isNaN(expiry.getTime())) {
throw new Error('Invalid expiry date provided');
}
// Allow dates within buffer window to support checking expiry of recently expired sessions
const bufferMs = bufferMinutes * 60 * 1000;
const expiryWithBuffer = expiry.getTime() + bufferMs;
if (expiryWithBuffer < Date.now()) {
throw new Error('Expiry date cannot be in the past');
}
}
this.expiry = expiry;
this.bufferMinutes = bufferMinutes;
}
/**
* Check if the session is expired.
* Considers the buffer time - sessions within the buffer window are treated as expired.
*
* @returns true if expired or expiring soon (within buffer), false otherwise
*/
isExpired(): boolean {
if (this.expiry === null) {
return false;
}
const bufferMs = this.bufferMinutes * 60 * 1000;
const expiryWithBuffer = this.expiry.getTime() - bufferMs;
return Date.now() >= expiryWithBuffer;
}
/**
* Check if the session is expiring soon (within buffer window).
*
* @returns true if expiring within buffer window, false otherwise
*/
isExpiringSoon(): boolean {
if (this.expiry === null) {
return false;
}
const bufferMs = this.bufferMinutes * 60 * 1000;
const now = Date.now();
const expiryTime = this.expiry.getTime();
const expiryWithBuffer = expiryTime - bufferMs;
return now >= expiryWithBuffer && now < expiryTime;
}
/**
* Get the expiry date.
*
* @returns The expiry date or null if no expiration
*/
getExpiry(): Date | null {
return this.expiry;
}
/**
* Get remaining time until expiry in milliseconds.
*
* @returns Milliseconds until expiry, or Infinity if no expiration
*/
getRemainingTime(): number {
if (this.expiry === null) {
return Infinity;
}
const remaining = this.expiry.getTime() - Date.now();
return Math.max(0, remaining);
}
}

View File

@@ -0,0 +1,96 @@
export type SessionStateValue =
| 'PENDING'
| 'IN_PROGRESS'
| 'PAUSED'
| 'COMPLETED'
| 'FAILED'
| 'STOPPED_AT_STEP_18'
| 'AWAITING_CHECKOUT_CONFIRMATION'
| 'CANCELLED';
const VALID_STATES: SessionStateValue[] = [
'PENDING',
'IN_PROGRESS',
'PAUSED',
'COMPLETED',
'FAILED',
'STOPPED_AT_STEP_18',
'AWAITING_CHECKOUT_CONFIRMATION',
'CANCELLED',
];
const VALID_TRANSITIONS: Record<SessionStateValue, SessionStateValue[]> = {
PENDING: ['IN_PROGRESS', 'FAILED'],
IN_PROGRESS: ['PAUSED', 'COMPLETED', 'FAILED', 'STOPPED_AT_STEP_18', 'AWAITING_CHECKOUT_CONFIRMATION'],
PAUSED: ['IN_PROGRESS', 'FAILED'],
COMPLETED: [],
FAILED: [],
STOPPED_AT_STEP_18: [],
AWAITING_CHECKOUT_CONFIRMATION: ['COMPLETED', 'CANCELLED', 'FAILED'],
CANCELLED: [],
};
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';
}
isAwaitingCheckoutConfirmation(): boolean {
return this._value === 'AWAITING_CHECKOUT_CONFIRMATION';
}
isCancelled(): boolean {
return this._value === 'CANCELLED';
}
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' ||
this._value === 'CANCELLED'
);
}
}

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 > 17) {
throw new Error('StepId must be between 1 and 17');
}
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 === 17;
}
next(): StepId {
if (this.isFinalStep()) {
throw new Error('Cannot advance beyond final step');
}
return StepId.create(this._value + 1);
}
}

View File

@@ -0,0 +1,18 @@
export * from './domain/value-objects/StepId';
export * from './domain/value-objects/CheckoutState';
export * from './domain/value-objects/RaceCreationResult';
export * from './domain/value-objects/CheckoutPrice';
export * from './domain/value-objects/CheckoutConfirmation';
export * from './domain/value-objects/AuthenticationState';
export * from './domain/value-objects/BrowserAuthenticationState';
export * from './domain/value-objects/CookieConfiguration';
export * from './domain/value-objects/ScreenRegion';
export * from './domain/value-objects/SessionLifetime';
export * from './domain/value-objects/SessionState';
export * from './domain/entities/HostedSessionConfig';
export * from './domain/entities/StepExecution';
export * from './domain/entities/AutomationSession';
export * from './domain/services/PageStateValidator';
export * from './domain/services/StepTransitionValidator';

View File

@@ -0,0 +1,8 @@
import { AutomationEvent } from '../../application/ports/IAutomationEventPublisher';
export type LifecycleCallback = (event: AutomationEvent) => Promise<void> | void;
export interface IAutomationLifecycleEmitter {
onLifecycle(cb: LifecycleCallback): void;
offLifecycle(cb: LifecycleCallback): void;
}

View File

@@ -0,0 +1,106 @@
import { Result } from '../../../shared/result/Result';
import { CheckoutPrice } from '@gridpilot/automation/domain/value-objects/CheckoutPrice';
import { CheckoutState } from '@gridpilot/automation/domain/value-objects/CheckoutState';
import { CheckoutInfo } from '../../../application/ports/ICheckoutService';
import { IRACING_SELECTORS } from './dom/IRacingSelectors';
interface Page {
locator(selector: string): Locator;
}
interface Locator {
first(): Locator;
locator(selector: string): Locator;
getAttribute(name: string): Promise<string | null>;
innerHTML(): Promise<string>;
textContent(): Promise<string | null>;
}
export class CheckoutPriceExtractor {
// Use the price action selector from IRACING_SELECTORS
private readonly selector = IRACING_SELECTORS.BLOCKED_SELECTORS.priceAction;
constructor(private readonly page: Page) {}
async extractCheckoutInfo(): Promise<Result<CheckoutInfo>> {
try {
// Prefer the explicit pill element which contains the price
const pillLocator = this.page.locator('.label-pill, .label-inverse');
const pillText = await pillLocator.first().textContent().catch(() => null);
let price: CheckoutPrice | null = null;
let state = CheckoutState.unknown();
let buttonHtml = '';
if (pillText) {
// Parse price if possible
try {
price = CheckoutPrice.fromString(pillText.trim());
} catch {
price = null;
}
// Try to find the containing button and its classes/html
// Primary: locate button via known selector that contains the pill
const buttonLocator = this.page.locator(this.selector).first();
let classes = await buttonLocator.getAttribute('class').catch(() => null);
let html = await buttonLocator.innerHTML().catch(() => '');
if (!classes) {
// Fallback: find ancestor <a> of the pill (XPath)
const ancestorButton = pillLocator.first().locator('xpath=ancestor::a[1]');
classes = await ancestorButton.getAttribute('class').catch(() => null);
html = await ancestorButton.innerHTML().catch(() => '');
}
if (classes) {
state = CheckoutState.fromButtonClasses(classes);
buttonHtml = html ?? '';
}
} else {
// No pill found — attempt to read button directly (best-effort)
const buttonLocator = this.page.locator(this.selector).first();
const classes = await buttonLocator.getAttribute('class').catch(() => null);
const html = await buttonLocator.innerHTML().catch(() => '');
if (classes) {
state = CheckoutState.fromButtonClasses(classes);
buttonHtml = html ?? '';
}
}
// Additional fallback: search the wizard-footer for any price text if pill was not present or parsing failed
if (!price) {
try {
const footerLocator = this.page.locator('.wizard-footer, .modal-footer').first();
const footerText = await footerLocator.textContent().catch(() => null);
if (footerText) {
const match = footerText.match(/\$\d+\.\d{2}/);
if (match) {
try {
price = CheckoutPrice.fromString(match[0]);
} catch {
price = null;
}
}
}
} catch {
// ignore footer parse errors
}
}
return Result.ok({
price,
state,
buttonHtml
});
} catch (error) {
// On any unexpected error, return an "unknown" result (do not throw)
return Result.ok({
price: null,
state: CheckoutState.unknown(),
buttonHtml: ''
});
}
}
}

View File

@@ -0,0 +1,41 @@
import { Page } from 'playwright';
import { ILogger } from '../../../../application/ports/ILogger';
export class AuthenticationGuard {
constructor(
private readonly page: Page,
private readonly logger?: ILogger
) {}
async checkForLoginUI(): Promise<boolean> {
const loginSelectors = [
'text="You are not logged in"',
':not(.chakra-menu):not([role="menu"]) button:has-text("Log in")',
'button[aria-label="Log in"]',
];
for (const selector of loginSelectors) {
try {
const element = this.page.locator(selector).first();
const isVisible = await element.isVisible().catch(() => false);
if (isVisible) {
this.logger?.warn('Login UI detected - user not authenticated', {
selector,
});
return true;
}
} catch {
// Selector not found, continue checking
}
}
return false;
}
async failFastIfUnauthenticated(): Promise<void> {
if (await this.checkForLoginUI()) {
throw new Error('Authentication required: Login UI detected on page');
}
}
}

View File

@@ -0,0 +1,123 @@
import type { Page } from 'playwright';
import type { ILogger } from '../../../../application/ports/ILogger';
import type { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
import { IRACING_URLS, IRACING_SELECTORS, IRACING_TIMEOUTS } from '../dom/IRacingSelectors';
import { AuthenticationGuard } from './AuthenticationGuard';
export class IRacingPlaywrightAuthFlow implements IPlaywrightAuthFlow {
constructor(private readonly logger?: ILogger) {}
getLoginUrl(): string {
return IRACING_URLS.login;
}
getPostLoginLandingUrl(): string {
return IRACING_URLS.hostedSessions;
}
isLoginUrl(url: string): boolean {
const lower = url.toLowerCase();
return (
lower.includes('oauth.iracing.com') ||
lower.includes('/membersite/login') ||
lower.includes('/login.jsp') ||
lower.includes('/login')
);
}
isAuthenticatedUrl(url: string): boolean {
const lower = url.toLowerCase();
return (
lower.includes('/web/racing/hosted') ||
lower.includes('/membersite/member') ||
lower.includes('members-ng.iracing.com') ||
lower.startsWith(IRACING_URLS.hostedSessions.toLowerCase()) ||
lower.startsWith(IRACING_URLS.home.toLowerCase())
);
}
isLoginSuccessUrl(url: string): boolean {
return this.isAuthenticatedUrl(url) && !this.isLoginUrl(url);
}
async detectAuthenticatedUi(page: Page): Promise<boolean> {
const authSelectors = [
IRACING_SELECTORS.hostedRacing.createRaceButton,
'[aria-label*="user menu" i]',
'[aria-label*="account menu" i]',
'.user-menu',
'.account-menu',
'nav a[href*="/membersite"]',
'nav a[href*="/members"]',
];
for (const selector of authSelectors) {
try {
const element = page.locator(selector).first();
const isVisible = await element.isVisible().catch(() => false);
if (isVisible) {
this.logger?.info?.('Authenticated UI detected', { selector });
return true;
}
} catch {
// Ignore selector errors, try next selector
}
}
return false;
}
async detectLoginUi(page: Page): Promise<boolean> {
const guard = new AuthenticationGuard(page, this.logger);
return guard.checkForLoginUI();
}
async navigateToAuthenticatedArea(page: Page): Promise<void> {
await page.goto(this.getPostLoginLandingUrl(), {
waitUntil: 'domcontentloaded',
timeout: IRACING_TIMEOUTS.navigation,
});
}
async waitForPostLoginRedirect(page: Page, timeoutMs: number): Promise<boolean> {
const start = Date.now();
this.logger?.info?.('Waiting for post-login redirect', { timeoutMs });
while (Date.now() - start < timeoutMs) {
try {
if (page.isClosed()) {
this.logger?.warn?.('Page closed while waiting for post-login redirect');
return false;
}
const url = page.url();
if (this.isLoginSuccessUrl(url)) {
this.logger?.info?.('Login success detected by URL', { url });
return true;
}
// Fallback: detect authenticated UI even if URL is not the canonical one
const hasAuthUi = await this.detectAuthenticatedUi(page);
if (hasAuthUi) {
this.logger?.info?.('Login success detected by authenticated UI', { url });
return true;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger?.debug?.('Error while waiting for post-login redirect', { error: message });
if (page.isClosed()) {
return false;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
this.logger?.warn?.('Post-login redirect wait timed out', { timeoutMs });
return false;
}
}

View File

@@ -0,0 +1,50 @@
import type { Page } from 'playwright';
/**
* Infra-level abstraction for Playwright-based authentication flows.
*
* Encapsulates game/site-specific URL patterns and UI detection so that
* auth/session orchestration can remain generic and reusable.
*/
export interface IPlaywrightAuthFlow {
/** Get the URL of the login page. */
getLoginUrl(): string;
/**
* Get a canonical URL that indicates the user is in an authenticated
* area suitable for running automation (e.g. hosted sessions dashboard).
*/
getPostLoginLandingUrl(): string;
/** True if the given URL points at the login experience. */
isLoginUrl(url: string): boolean;
/** True if the given URL is considered authenticated (members area). */
isAuthenticatedUrl(url: string): boolean;
/**
* True if the URL represents a successful login redirect, distinct from
* the raw login form page or intermediate OAuth pages.
*/
isLoginSuccessUrl(url: string): boolean;
/** Detect whether an authenticated UI is currently rendered. */
detectAuthenticatedUi(page: Page): Promise<boolean>;
/** Detect whether a login UI is currently rendered. */
detectLoginUi(page: Page): Promise<boolean>;
/**
* Navigate the given page into an authenticated area that the automation
* engine can assume as a starting point after login.
*/
navigateToAuthenticatedArea(page: Page): Promise<void>;
/**
* Wait for the browser to reach a post-login state within the timeout.
*
* Implementations may use URL changes, UI detection, or a combination of
* both to determine success.
*/
waitForPostLoginRedirect(page: Page, timeoutMs: number): Promise<boolean>;
}

View File

@@ -0,0 +1,456 @@
import * as fs from 'fs';
import type { BrowserContext, Page } from 'playwright';
import type { IAuthenticationService } from '../../../../application/ports/IAuthenticationService';
import type { ILogger } from '../../../../application/ports/ILogger';
import { AuthenticationState } from '@gridpilot/automation/domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from '@gridpilot/automation/domain/value-objects/BrowserAuthenticationState';
import { Result } from '../../../../shared/result/Result';
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
import { SessionCookieStore } from './SessionCookieStore';
import type { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
import { AuthenticationGuard } from './AuthenticationGuard';
interface PlaywrightAuthSessionConfig {
navigationTimeoutMs?: number;
loginWaitTimeoutMs?: number;
}
/**
* Game-agnostic Playwright-based authentication/session service.
*
* All game/site-specific behavior (URLs, selectors, redirects) is delegated to
* the injected IPlaywrightAuthFlow implementation. This class is responsible
* only for:
* - Browser/session orchestration via PlaywrightBrowserSession
* - Cookie persistence via SessionCookieStore
* - Exposing the IAuthenticationService port for application layer
*/
export class PlaywrightAuthSessionService implements IAuthenticationService {
private readonly browserSession: PlaywrightBrowserSession;
private readonly cookieStore: SessionCookieStore;
private readonly authFlow: IPlaywrightAuthFlow;
private readonly logger?: ILogger;
private readonly navigationTimeoutMs: number;
private readonly loginWaitTimeoutMs: number;
private authState: AuthenticationState = AuthenticationState.UNKNOWN;
constructor(
browserSession: PlaywrightBrowserSession,
cookieStore: SessionCookieStore,
authFlow: IPlaywrightAuthFlow,
logger?: ILogger,
config?: PlaywrightAuthSessionConfig,
) {
this.browserSession = browserSession;
this.cookieStore = cookieStore;
this.authFlow = authFlow;
this.logger = logger;
this.navigationTimeoutMs = config?.navigationTimeoutMs ?? 30000;
this.loginWaitTimeoutMs = config?.loginWaitTimeoutMs ?? 300000;
}
// ===== Logging =====
private log(
level: 'debug' | 'info' | 'warn' | 'error',
message: string,
context?: Record<string, unknown>,
): void {
if (!this.logger) {
return;
}
const logger: any = this.logger;
logger[level](message, context as any);
}
// ===== Helpers =====
private getContext(): BrowserContext | null {
return this.browserSession.getPersistentContext() ?? this.browserSession.getContext();
}
private getPageOrError(): Result<Page> {
const page = this.browserSession.getPage();
if (!page) {
return Result.err(new Error('Browser not connected'));
}
return Result.ok(page);
}
private async injectCookiesBeforeNavigation(targetUrl: string): Promise<Result<void>> {
const context = this.getContext();
if (!context) {
return Result.err(new Error('No browser context available'));
}
try {
const state = await this.cookieStore.read();
if (!state || state.cookies.length === 0) {
return Result.err(new Error('No cookies found in session store'));
}
const validCookies = this.cookieStore.getValidCookiesForUrl(targetUrl);
if (validCookies.length === 0) {
this.log('warn', 'No valid cookies found for target URL', {
targetUrl,
totalCookies: state.cookies.length,
});
return Result.err(new Error('No valid cookies found for target URL'));
}
await context.addCookies(validCookies);
this.log('info', 'Cookies injected successfully', {
count: validCookies.length,
targetUrl,
cookieNames: validCookies.map((c) => c.name),
});
return Result.ok(undefined);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Result.err(new Error(`Cookie injection failed: ${message}`));
}
}
private async saveSessionState(): Promise<void> {
const context = this.getContext();
if (!context) {
this.log('warn', 'No browser context available to save session state');
return;
}
try {
const storageState = await context.storageState();
await this.cookieStore.write(storageState);
this.log('info', 'Session state saved to cookie store');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed to save session state', { error: message });
throw error;
}
}
// ===== IAuthenticationService implementation =====
async checkSession(): Promise<Result<AuthenticationState>> {
try {
this.log('info', 'Checking session from cookie store');
const state = await this.cookieStore.read();
if (!state) {
this.authState = AuthenticationState.UNKNOWN;
this.log('info', 'No session state file found');
return Result.ok(this.authState);
}
this.authState = this.cookieStore.validateCookies(state.cookies);
this.log('info', 'Session check complete', { state: this.authState });
return Result.ok(this.authState);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Session check failed', { error: message });
return Result.err(new Error(`Session check failed: ${message}`));
}
}
getLoginUrl(): string {
return this.authFlow.getLoginUrl();
}
async initiateLogin(): Promise<Result<void>> {
try {
const forceHeaded = true;
this.log('info', 'Opening login in headed Playwright browser (forceHeaded=true)', {
forceHeaded,
});
const connectResult = await this.browserSession.connect(forceHeaded);
if (!connectResult.success) {
return Result.err(new Error(connectResult.error || 'Failed to connect browser'));
}
const pageResult = this.getPageOrError();
if (pageResult.isErr()) {
return Result.err(pageResult.unwrapErr());
}
const page = pageResult.unwrap();
const loginUrl = this.authFlow.getLoginUrl();
await page.goto(loginUrl, {
waitUntil: 'domcontentloaded',
timeout: this.navigationTimeoutMs,
});
this.log('info', forceHeaded
? 'Browser opened to login page in headed mode, waiting for login...'
: 'Browser opened to login page, waiting for login...');
this.authState = AuthenticationState.UNKNOWN;
const loginSuccess = await this.authFlow.waitForPostLoginRedirect(
page,
this.loginWaitTimeoutMs,
);
if (loginSuccess) {
this.log('info', 'Login detected, saving session state');
await this.saveSessionState();
const state = await this.cookieStore.read();
if (state && this.cookieStore.validateCookies(state.cookies) === AuthenticationState.AUTHENTICATED) {
this.authState = AuthenticationState.AUTHENTICATED;
this.log('info', 'Session saved and validated successfully');
} else {
this.authState = AuthenticationState.UNKNOWN;
this.log('warn', 'Session saved but validation unclear');
}
this.log('info', 'Closing browser after successful login');
await this.browserSession.disconnect();
return Result.ok(undefined);
}
this.log('warn', 'Login was not completed');
await this.browserSession.disconnect();
return Result.err(new Error('Login timeout - please try again'));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed during login process', { error: message });
try {
await this.browserSession.disconnect();
} catch {
}
return Result.err(error instanceof Error ? error : new Error(message));
}
}
async confirmLoginComplete(): Promise<Result<void>> {
try {
this.log('info', 'User confirmed login complete');
await this.saveSessionState();
const state = await this.cookieStore.read();
if (state && this.cookieStore.validateCookies(state.cookies) === AuthenticationState.AUTHENTICATED) {
this.authState = AuthenticationState.AUTHENTICATED;
this.log('info', 'Login confirmed and session saved successfully');
} else {
this.authState = AuthenticationState.UNKNOWN;
this.log('warn', 'Login confirmation received but session state unclear');
}
return Result.ok(undefined);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed to confirm login', { error: message });
return Result.err(error instanceof Error ? error : new Error(message));
}
}
async clearSession(): Promise<Result<void>> {
try {
this.log('info', 'Clearing session');
await this.cookieStore.delete();
this.log('debug', 'Cookie store deleted');
const userDataDir = this.browserSession.getUserDataDir();
if (userDataDir && fs.existsSync(userDataDir)) {
this.log('debug', 'Removing user data directory', { path: userDataDir });
fs.rmSync(userDataDir, { recursive: true, force: true });
}
this.authState = AuthenticationState.LOGGED_OUT;
this.log('info', 'Session cleared successfully');
return Result.ok(undefined);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed to clear session', { error: message });
return Result.err(new Error(`Failed to clear session: ${message}`));
}
}
getState(): AuthenticationState {
return this.authState;
}
async validateServerSide(): Promise<Result<boolean>> {
try {
this.log('info', 'Performing server-side session validation');
const context = this.getContext();
if (!context) {
return Result.err(new Error('No browser context available'));
}
const page = await context.newPage();
try {
const response = await page.goto(this.authFlow.getPostLoginLandingUrl(), {
waitUntil: 'domcontentloaded',
timeout: this.navigationTimeoutMs,
});
if (!response) {
return Result.ok(false);
}
const finalUrl = page.url();
const isOnLoginPage = this.authFlow.isLoginUrl(finalUrl);
const isValid = !isOnLoginPage;
this.log('info', 'Server-side validation complete', { isValid, finalUrl });
return Result.ok(isValid);
} finally {
await page.close();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('warn', 'Server-side validation failed', { error: message });
return Result.err(new Error(`Server validation failed: ${message}`));
}
}
async refreshSession(): Promise<Result<void>> {
try {
this.log('info', 'Refreshing session from cookie store');
const state = await this.cookieStore.read();
if (!state) {
this.authState = AuthenticationState.UNKNOWN;
return Result.ok(undefined);
}
this.authState = this.cookieStore.validateCookies(state.cookies);
this.log('info', 'Session refreshed', { state: this.authState });
return Result.ok(undefined);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Session refresh failed', { error: message });
return Result.err(new Error(`Session refresh failed: ${message}`));
}
}
async getSessionExpiry(): Promise<Result<Date | null>> {
try {
const expiry = await this.cookieStore.getSessionExpiry();
return Result.ok(expiry);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed to get session expiry', { error: message });
return Result.err(new Error(`Failed to get session expiry: ${message}`));
}
}
async verifyPageAuthentication(): Promise<Result<BrowserAuthenticationState>> {
const pageResult = this.getPageOrError();
if (pageResult.isErr()) {
return Result.err(pageResult.unwrapErr());
}
const page = pageResult.unwrap();
try {
const url = page.url();
const isOnAuthenticatedPath = this.authFlow.isAuthenticatedUrl(url);
const isOnLoginPath = this.authFlow.isLoginUrl(url);
const guard = new AuthenticationGuard(page, this.logger);
const hasLoginUI = await guard.checkForLoginUI();
const hasAuthUI = await this.authFlow.detectAuthenticatedUi(page);
const cookieResult = await this.checkSession();
const cookiesValid =
cookieResult.isOk() &&
cookieResult.unwrap() === AuthenticationState.AUTHENTICATED;
const pageAuthenticated =
!hasLoginUI &&
!isOnLoginPath &&
((isOnAuthenticatedPath && cookiesValid) || hasAuthUI);
this.log('debug', 'Page authentication check', {
url,
isOnAuthenticatedPath,
isOnLoginPath,
hasLoginUI,
hasAuthUI,
cookiesValid,
pageAuthenticated,
});
return Result.ok(new BrowserAuthenticationState(cookiesValid, pageAuthenticated));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Result.err(new Error(`Page verification failed: ${message}`));
}
}
// ===== Public helper for navigation with cookie injection =====
/**
* Navigate to an authenticated area using stored cookies when possible.
* Not part of the IAuthenticationService port, but useful for internal
* orchestration (e.g. within automation flows).
*/
async navigateWithExistingSession(forceHeaded: boolean = false): Promise<Result<void>> {
try {
const sessionResult = await this.checkSession();
if (
sessionResult.isOk() &&
sessionResult.unwrap() === AuthenticationState.AUTHENTICATED
) {
this.log('info', 'Session cookies found, launching in configured browser mode');
await this.browserSession.ensureBrowserContext(forceHeaded);
const pageResult = this.getPageOrError();
if (pageResult.isErr()) {
return Result.err(pageResult.unwrapErr());
}
const page = pageResult.unwrap();
const targetUrl = this.authFlow.getPostLoginLandingUrl();
const injectResult = await this.injectCookiesBeforeNavigation(targetUrl);
if (injectResult.isErr()) {
this.log('warn', 'Cookie injection failed, falling back to manual login', {
error: injectResult.error?.message ?? 'unknown error',
});
return Result.err(injectResult.unwrapErr());
}
await page.goto(targetUrl, {
waitUntil: 'domcontentloaded',
timeout: this.navigationTimeoutMs,
});
const verifyResult = await this.verifyPageAuthentication();
if (verifyResult.isOk()) {
const browserState = verifyResult.unwrap();
if (browserState.isFullyAuthenticated()) {
this.log('info', 'Authentication verified successfully after cookie navigation');
return Result.ok(undefined);
}
this.log('warn', 'Page shows unauthenticated state despite cookies');
}
return Result.err(new Error('Page not authenticated after cookie navigation'));
}
return Result.err(new Error('No valid session cookies found'));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed to navigate with existing session', { error: message });
return Result.err(new Error(`Failed to navigate with existing session: ${message}`));
}
}
}

View File

@@ -0,0 +1,372 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { AuthenticationState } from '@gridpilot/automation/domain/value-objects/AuthenticationState';
import { CookieConfiguration } from '@gridpilot/automation/domain/value-objects/CookieConfiguration';
import { Result } from '../../../../shared/result/Result';
import type { ILogger } from '../../../../application/ports/ILogger';
interface Cookie {
name: string;
value: string;
domain: string;
path: string;
expires: number;
}
interface StorageState {
cookies: Cookie[];
origins: unknown[];
}
/**
* Known iRacing session cookie names to look for.
* These are the primary authentication indicators.
*/
const IRACING_SESSION_COOKIES = [
'irsso_members',
'authtoken_members',
'irsso',
'authtoken',
];
/**
* iRacing domain patterns to match cookies against.
*/
const IRACING_DOMAINS = [
'iracing.com',
'.iracing.com',
'members.iracing.com',
'members-ng.iracing.com',
];
const EXPIRY_BUFFER_SECONDS = 300;
export class SessionCookieStore {
private readonly storagePath: string;
private logger?: ILogger;
constructor(userDataDir: string, logger?: ILogger) {
this.storagePath = path.join(userDataDir, 'session-state.json');
this.logger = logger;
}
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
if (this.logger) {
if (level === 'error') {
this.logger.error(message, undefined, context);
} else {
this.logger[level](message, context);
}
}
}
getPath(): string {
return this.storagePath;
}
async read(): Promise<StorageState | null> {
try {
const content = await fs.readFile(this.storagePath, 'utf-8');
const state = JSON.parse(content) as StorageState;
// Ensure all cookies have path field (default to "/" for backward compatibility)
state.cookies = state.cookies.map(cookie => ({
...cookie,
path: cookie.path || '/'
}));
this.cachedState = state;
return state;
} catch {
return null;
}
}
async write(state: StorageState): Promise<void> {
this.cachedState = state;
await fs.writeFile(this.storagePath, JSON.stringify(state, null, 2), 'utf-8');
}
async delete(): Promise<void> {
try {
await fs.unlink(this.storagePath);
} catch {
// File may not exist, ignore
}
}
/**
* Get session expiry date from iRacing cookies.
* Returns the earliest expiry date from valid session cookies.
*/
async getSessionExpiry(): Promise<Date | null> {
try {
const state = await this.read();
if (!state || state.cookies.length === 0) {
return null;
}
// Filter to iRacing authentication cookies
const authCookies = state.cookies.filter(c =>
IRACING_DOMAINS.some(domain =>
c.domain === domain || c.domain.endsWith(domain)
) &&
(IRACING_SESSION_COOKIES.some(name =>
c.name.toLowerCase().includes(name.toLowerCase())
) ||
c.name.toLowerCase().includes('auth') ||
c.name.toLowerCase().includes('sso') ||
c.name.toLowerCase().includes('token'))
);
if (authCookies.length === 0) {
return null;
}
// Find the earliest expiry date (most restrictive)
// Session cookies (expires = -1 or 0) are treated as never expiring
const expiryDates = authCookies
.filter(c => c.expires > 0)
.map(c => {
// Handle both formats: seconds (standard) and milliseconds (test fixtures)
// If expires > year 2100 in seconds (33134745600), it's likely milliseconds
const isMilliseconds = c.expires > 33134745600;
return new Date(isMilliseconds ? c.expires : c.expires * 1000);
});
if (expiryDates.length === 0) {
// All session cookies, no expiry
return null;
}
// Return earliest expiry
const earliestExpiry = new Date(Math.min(...expiryDates.map(d => d.getTime())));
this.log('debug', 'Session expiry determined', {
earliestExpiry: earliestExpiry.toISOString(),
cookiesChecked: authCookies.length
});
return earliestExpiry;
} catch (error) {
this.log('error', 'Failed to get session expiry', { error: String(error) });
return null;
}
}
/**
* Validate cookies and determine authentication state.
*
* Looks for iRacing session cookies by checking:
* 1. Domain matches iRacing patterns
* 2. Cookie name matches known session cookie names OR
* 3. Any cookie from members.iracing.com domain (fallback)
*/
validateCookies(cookies: Cookie[]): AuthenticationState {
// Log all cookies for debugging
this.log('debug', 'Validating cookies', {
totalCookies: cookies.length,
cookieNames: cookies.map(c => ({ name: c.name, domain: c.domain }))
});
// Filter cookies from iRacing domains
const iracingDomainCookies = cookies.filter(c =>
IRACING_DOMAINS.some(domain =>
c.domain === domain || c.domain.endsWith(domain)
)
);
this.log('debug', 'iRacing domain cookies found', {
count: iracingDomainCookies.length,
cookies: iracingDomainCookies.map(c => ({
name: c.name,
domain: c.domain,
expires: c.expires,
expiresDate: new Date(c.expires * 1000).toISOString()
}))
});
// Look for known session cookies first
const knownSessionCookies = iracingDomainCookies.filter(c =>
IRACING_SESSION_COOKIES.some(name =>
c.name.toLowerCase() === name.toLowerCase() ||
c.name.toLowerCase().includes(name.toLowerCase())
)
);
// If no known session cookies, check for any auth-like cookies from members domain
const authCookies = knownSessionCookies.length > 0
? knownSessionCookies
: iracingDomainCookies.filter(c =>
c.domain.includes('members') &&
(c.name.toLowerCase().includes('auth') ||
c.name.toLowerCase().includes('sso') ||
c.name.toLowerCase().includes('session') ||
c.name.toLowerCase().includes('token'))
);
this.log('debug', 'Authentication cookies identified', {
knownSessionCookiesCount: knownSessionCookies.length,
authCookiesCount: authCookies.length,
cookies: authCookies.map(c => ({ name: c.name, domain: c.domain }))
});
if (authCookies.length === 0) {
// Last resort: if we have ANY cookies from members.iracing.com, consider it potentially valid
const membersCookies = iracingDomainCookies.filter(c =>
c.domain.includes('members.iracing.com') || c.domain === '.iracing.com'
);
if (membersCookies.length > 0) {
this.log('info', 'No known auth cookies found, but members domain cookies exist', {
count: membersCookies.length,
cookies: membersCookies.map(c => c.name)
});
// Check expiry on any of these cookies
const now = Math.floor(Date.now() / 1000);
const hasValidCookie = membersCookies.some(c =>
c.expires === -1 || c.expires === 0 || c.expires > (now + EXPIRY_BUFFER_SECONDS)
);
return hasValidCookie
? AuthenticationState.AUTHENTICATED
: AuthenticationState.EXPIRED;
}
this.log('info', 'No iRacing authentication cookies found');
return AuthenticationState.UNKNOWN;
}
// Check if any auth cookie is still valid (not expired)
const now = Math.floor(Date.now() / 1000);
const validCookies = authCookies.filter(c => {
// Handle session cookies (expires = -1 or 0) and persistent cookies
const isSession = c.expires === -1 || c.expires === 0;
const isNotExpired = c.expires > (now + EXPIRY_BUFFER_SECONDS);
return isSession || isNotExpired;
});
this.log('debug', 'Cookie expiry check', {
now,
validCookiesCount: validCookies.length,
cookies: authCookies.map(c => ({
name: c.name,
expires: c.expires,
isValid: c.expires === -1 || c.expires === 0 || c.expires > (now + EXPIRY_BUFFER_SECONDS)
}))
});
if (validCookies.length > 0) {
this.log('info', 'Valid iRacing session cookies found', { count: validCookies.length });
return AuthenticationState.AUTHENTICATED;
}
this.log('info', 'iRacing session cookies found but all expired');
return AuthenticationState.EXPIRED;
}
private cachedState: StorageState | null = null;
/**
* Validate stored cookies for a target URL.
* Note: This requires cookies to be written first via write().
* This is synchronous because tests expect it - uses cached state.
* Validates domain/path compatibility AND checks for required authentication cookies.
*/
validateCookieConfiguration(targetUrl: string): Result<Cookie[]> {
try {
if (!this.cachedState || this.cachedState.cookies.length === 0) {
return Result.err<Cookie[]>(new Error('No cookies found in session store'));
}
return this.validateCookiesForUrl(this.cachedState.cookies, targetUrl, true);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Result.err<Cookie[]>(new Error(`Cookie validation failed: ${message}`));
}
}
/**
* Validate a list of cookies for a target URL.
* Returns only cookies that are valid for the target URL.
* @param requireAuthCookies - If true, checks for required authentication cookies
*/
validateCookiesForUrl(
cookies: Cookie[],
targetUrl: string,
requireAuthCookies = false
): Result<Cookie[]> {
try {
const validatedCookies: Cookie[] = [];
let firstValidationError: Error | null = null;
for (const cookie of cookies) {
try {
new CookieConfiguration(cookie, targetUrl);
validatedCookies.push(cookie);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
if (!firstValidationError) {
firstValidationError = err;
}
this.logger?.warn('Cookie validation failed', {
name: cookie.name,
error: err.message,
});
}
}
if (validatedCookies.length === 0) {
return Result.err<Cookie[]>(
firstValidationError ?? new Error('No valid cookies found for target URL')
);
}
if (requireAuthCookies) {
const cookieNames = validatedCookies.map((c) => c.name.toLowerCase());
const hasIrssoMembers = cookieNames.some((name) =>
name.includes('irsso_members') || name.includes('irsso')
);
const hasAuthtokenMembers = cookieNames.some((name) =>
name.includes('authtoken_members') || name.includes('authtoken')
);
if (!hasIrssoMembers) {
return Result.err<Cookie[]>(new Error('Required cookie missing: irsso_members'));
}
if (!hasAuthtokenMembers) {
return Result.err<Cookie[]>(new Error('Required cookie missing: authtoken_members'));
}
}
return Result.ok<Cookie[]>(validatedCookies);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
return Result.err<Cookie[]>(new Error(`Cookie validation failed: ${err.message}`));
}
}
/**
* Get cookies that are valid for a target URL.
* Returns array of cookies (empty if none valid).
* Uses cached state from last write().
*/
getValidCookiesForUrl(targetUrl: string): Cookie[] {
try {
if (!this.cachedState || this.cachedState.cookies.length === 0) {
return [];
}
const result = this.validateCookiesForUrl(this.cachedState.cookies, targetUrl);
return result.isOk() ? result.unwrap() : [];
} catch {
return [];
}
}
}

View File

@@ -0,0 +1,298 @@
import { Browser, BrowserContext, Page } from 'playwright';
import { chromium } from 'playwright-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import * as fs from 'fs';
import * as path from 'path';
import type { ILogger } from '../../../../application/ports/ILogger';
import { BrowserModeConfigLoader, BrowserMode } from '../../../config/BrowserModeConfig';
import { getAutomationMode } from '../../../config/AutomationConfig';
import type { PlaywrightConfig } from './PlaywrightAutomationAdapter';
import { PlaywrightAutomationAdapter } from './PlaywrightAutomationAdapter';
chromium.use(StealthPlugin());
export type BrowserModeSource = string;
export class PlaywrightBrowserSession {
private browser: Browser | null = null;
private persistentContext: BrowserContext | null = null;
private context: BrowserContext | null = null;
private page: Page | null = null;
private connected = false;
private isConnecting = false;
private browserModeLoader: BrowserModeConfigLoader;
private actualBrowserMode: BrowserMode;
private browserModeSource: BrowserModeSource;
constructor(
private readonly config: Required<PlaywrightConfig>,
private readonly logger?: ILogger,
browserModeLoader?: BrowserModeConfigLoader,
) {
const automationMode = getAutomationMode();
this.browserModeLoader = browserModeLoader ?? new BrowserModeConfigLoader();
const browserModeConfig = this.browserModeLoader.load();
this.actualBrowserMode = browserModeConfig.mode;
this.browserModeSource = browserModeConfig.source as BrowserModeSource;
this.log('info', 'Browser mode configured', {
mode: this.actualBrowserMode,
source: this.browserModeSource,
automationMode,
configHeadless: this.config.headless,
});
}
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
if (!this.logger) {
return;
}
const logger: any = this.logger;
logger[level](message, context as any);
}
private isRealMode(): boolean {
return this.config.mode === 'real';
}
getBrowserMode(): BrowserMode {
return this.actualBrowserMode;
}
getBrowserModeSource(): BrowserModeSource {
return this.browserModeSource;
}
getUserDataDir(): string {
return this.config.userDataDir;
}
getPage(): Page | null {
return this.page;
}
getContext(): BrowserContext | null {
return this.context;
}
getPersistentContext(): BrowserContext | null {
return this.persistentContext;
}
getBrowser(): Browser | null {
return this.browser;
}
isConnected(): boolean {
return this.connected && this.page !== null;
}
async connect(forceHeaded: boolean = false): Promise<{ success: boolean; error?: string }> {
if (this.connected && this.page) {
const shouldReuse =
!forceHeaded ||
this.actualBrowserMode === 'headed';
if (shouldReuse) {
this.log('debug', 'Already connected, reusing existing connection', {
browserMode: this.actualBrowserMode,
forcedHeaded: forceHeaded,
});
return { success: true };
}
this.log('info', 'Existing browser connection is headless, reopening in headed mode for login', {
browserMode: this.actualBrowserMode,
forcedHeaded: forceHeaded,
});
await this.closeBrowserContext();
}
if (this.isConnecting) {
this.log('debug', 'Connection in progress, waiting...');
await new Promise(resolve => setTimeout(resolve, 100));
return this.connect(forceHeaded);
}
this.isConnecting = true;
try {
const currentConfig = this.browserModeLoader.load();
this.actualBrowserMode = currentConfig.mode;
this.browserModeSource = currentConfig.source as BrowserModeSource;
const effectiveMode = forceHeaded ? 'headed' : currentConfig.mode;
const adapterAny = PlaywrightAutomationAdapter as any;
const launcher = adapterAny.testLauncher ?? chromium;
this.log('debug', 'Effective browser mode at connect', {
effectiveMode,
actualBrowserMode: this.actualBrowserMode,
browserModeSource: this.browserModeSource,
forced: forceHeaded,
});
if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development') {
try {
const loaderValue = this.browserModeLoader && typeof this.browserModeLoader.load === 'function'
? this.browserModeLoader.load()
: undefined;
console.debug('[TEST-INSTRUMENT] PlaywrightAutomationAdapter.connect()', {
effectiveMode,
forceHeaded,
loaderValue,
browserModeSource: this.getBrowserModeSource(),
});
} catch {
// ignore instrumentation errors
}
}
if (this.isRealMode() && this.config.userDataDir) {
this.log('info', 'Launching persistent browser context', {
userDataDir: this.config.userDataDir,
mode: effectiveMode,
forced: forceHeaded,
});
if (!fs.existsSync(this.config.userDataDir)) {
fs.mkdirSync(this.config.userDataDir, { recursive: true });
}
await this.cleanupStaleLockFile(this.config.userDataDir);
this.persistentContext = await launcher.launchPersistentContext(
this.config.userDataDir,
{
headless: effectiveMode === 'headless',
args: [
'--disable-blink-features=AutomationControlled',
'--disable-features=IsolateOrigins,site-per-process',
],
ignoreDefaultArgs: ['--enable-automation'],
viewport: { width: 1920, height: 1080 },
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
);
const persistentContext = this.persistentContext!;
this.page = persistentContext.pages()[0] || await persistentContext.newPage();
this.page.setDefaultTimeout(this.config.timeout ?? 10000);
this.connected = !!this.page;
} else {
this.browser = await launcher.launch({
headless: effectiveMode === 'headless',
args: [
'--disable-blink-features=AutomationControlled',
'--disable-features=IsolateOrigins,site-per-process',
],
ignoreDefaultArgs: ['--enable-automation'],
});
const browser = this.browser!;
this.context = await browser.newContext();
this.page = await this.context.newPage();
this.page.setDefaultTimeout(this.config.timeout ?? 10000);
this.connected = !!this.page;
}
if (!this.page) {
this.log('error', 'Browser session connected without a usable page', {
hasBrowser: !!this.browser,
hasContext: !!this.context || !!this.persistentContext,
});
await this.closeBrowserContext();
this.connected = false;
return { success: false, error: 'Browser not connected' };
}
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.connected = false;
this.page = null;
this.context = null;
this.persistentContext = null;
this.browser = null;
return { success: false, error: message };
} finally {
this.isConnecting = false;
}
}
async ensureBrowserContext(forceHeaded: boolean = false): Promise<void> {
const result = await this.connect(forceHeaded);
if (!result.success) {
throw new Error(result.error || 'Failed to connect browser');
}
}
private async cleanupStaleLockFile(userDataDir: string): Promise<void> {
const singletonLockPath = path.join(userDataDir, 'SingletonLock');
try {
if (!fs.existsSync(singletonLockPath)) {
return;
}
this.log('info', 'Found existing SingletonLock, attempting cleanup', { path: singletonLockPath });
fs.unlinkSync(singletonLockPath);
this.log('info', 'Cleaned up stale SingletonLock file');
} catch (error) {
this.log('warn', 'Could not clean up SingletonLock', { error: String(error) });
}
}
async disconnect(): Promise<void> {
if (this.page) {
await this.page.close();
this.page = null;
}
if (this.persistentContext) {
await this.persistentContext.close();
this.persistentContext = null;
}
if (this.context) {
await this.context.close();
this.context = null;
}
if (this.browser) {
await this.browser.close();
this.browser = null;
}
this.connected = false;
}
async closeBrowserContext(): Promise<void> {
try {
if (this.persistentContext) {
await this.persistentContext.close();
this.persistentContext = null;
this.page = null;
this.connected = false;
this.log('info', 'Persistent context closed');
return;
}
if (this.context) {
await this.context.close();
this.context = null;
this.page = null;
}
if (this.browser) {
await this.browser.close();
this.browser = null;
}
this.connected = false;
this.log('info', 'Browser closed successfully');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('warn', 'Error closing browser context', { error: message });
this.persistentContext = null;
this.context = null;
this.browser = null;
this.page = null;
this.connected = false;
}
}
}

View File

@@ -0,0 +1,306 @@
import type { Page } from 'playwright';
import type { ILogger } from '../../../../application/ports/ILogger';
import type { NavigationResult, WaitResult } from '../../../../application/ports/AutomationResults';
import type { PlaywrightConfig } from '../core/PlaywrightAutomationAdapter';
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
import { IRACING_SELECTORS, IRACING_TIMEOUTS, IRACING_URLS } from './IRacingSelectors';
export class IRacingDomNavigator {
private static readonly STEP_TO_PAGE_MAP: Record<number, string> = {
7: 'timeLimit',
8: 'cars',
9: 'cars',
10: 'carClasses',
11: 'track',
12: 'track',
13: 'trackOptions',
14: 'timeOfDay',
15: 'weather',
16: 'raceOptions',
17: 'trackConditions',
};
constructor(
private readonly config: Required<PlaywrightConfig>,
private readonly browserSession: PlaywrightBrowserSession,
private readonly logger?: ILogger,
private readonly onWizardDismissed?: () => Promise<void>,
) {}
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
if (!this.logger) {
return;
}
const logger: any = this.logger;
logger[level](message, context as any);
}
private isRealMode(): boolean {
return this.config.mode === 'real';
}
private getPage(): Page | null {
return this.browserSession.getPage();
}
async navigateToPage(url: string): Promise<NavigationResult> {
const page = this.getPage();
if (!page) {
return { success: false, url, loadTime: 0, error: 'Browser not connected' };
}
const startTime = Date.now();
try {
const targetUrl = this.isRealMode() && !url.startsWith('http') ? IRACING_URLS.hostedSessions : url;
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.navigation : this.config.timeout;
this.log('debug', 'Navigating to page', { url: targetUrl, mode: this.config.mode });
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout });
const loadTime = Date.now() - startTime;
if (!this.isRealMode()) {
const stepMatch = url.match(/step-(\d+)-/);
if (stepMatch) {
const stepNumber = parseInt(stepMatch[1], 10);
await page.evaluate((step) => {
document.body.setAttribute('data-step', String(step));
}, stepNumber);
}
}
return { success: true, url: targetUrl, loadTime };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const loadTime = Date.now() - startTime;
return { success: false, url, loadTime, error: message };
}
}
async waitForElement(target: string, maxWaitMs?: number): Promise<WaitResult> {
const page = this.getPage();
if (!page) {
return { success: false, target, waitedMs: 0, found: false, error: 'Browser not connected' };
}
const startTime = Date.now();
const defaultTimeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
try {
let selector: string;
if (target.startsWith('[') || target.startsWith('button') || target.startsWith('#')) {
selector = target;
} else {
selector = IRACING_SELECTORS.wizard.modal;
}
this.log('debug', 'Waiting for element', { target, selector, mode: this.config.mode });
await page.waitForSelector(selector, {
state: 'attached',
timeout: maxWaitMs ?? defaultTimeout,
});
return { success: true, target, waitedMs: Date.now() - startTime, found: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
target,
waitedMs: Date.now() - startTime,
found: false,
error: message,
};
}
}
async waitForModal(): Promise<void> {
const page = this.getPage();
if (!page) {
throw new Error('Browser not connected');
}
const selector = IRACING_SELECTORS.wizard.modal;
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
await page.waitForSelector(selector, {
state: 'attached',
timeout,
});
}
async waitForStep(stepNumber: number): Promise<void> {
const page = this.getPage();
if (!page) {
throw new Error('Browser not connected');
}
if (!this.isRealMode()) {
await page.evaluate((step) => {
document.body.setAttribute('data-step', String(step));
}, stepNumber);
}
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
await page.waitForSelector(`[data-step="${stepNumber}"]`, {
state: 'attached',
timeout,
});
}
async waitForWizardStep(stepName: keyof typeof IRACING_SELECTORS.wizard.stepContainers): Promise<void> {
const page = this.getPage();
if (!page || !this.isRealMode()) return;
const containerSelector = IRACING_SELECTORS.wizard.stepContainers[stepName];
if (!containerSelector) {
this.log('warn', `Unknown wizard step: ${stepName}`);
return;
}
try {
this.log('debug', `Waiting for wizard step: ${stepName}`, { selector: containerSelector });
await page.waitForSelector(containerSelector, {
state: 'attached',
timeout: 15000,
});
await page.waitForTimeout(100);
} catch (error) {
this.log('warn', `Wizard step not attached: ${stepName}`, { error: String(error) });
}
}
async detectCurrentWizardPage(): Promise<string | null> {
const page = this.getPage();
if (!page) {
return null;
}
try {
const containers = IRACING_SELECTORS.wizard.stepContainers;
for (const [pageName, selector] of Object.entries(containers)) {
const count = await page.locator(selector).count();
if (count > 0) {
this.log('debug', 'Detected wizard page', { pageName, selector });
return pageName;
}
}
this.log('debug', 'No wizard page detected');
return null;
} catch (error) {
this.log('debug', 'Error detecting wizard page', { error: String(error) });
return null;
}
}
synchronizeStepCounter(expectedStep: number, actualPage: string | null): number {
if (!actualPage) {
return 0;
}
let actualStep: number | null = null;
for (const [step, pageName] of Object.entries(IRacingDomNavigator.STEP_TO_PAGE_MAP)) {
if (pageName === actualPage) {
actualStep = parseInt(step, 10);
break;
}
}
if (actualStep === null) {
return 0;
}
const skipOffset = actualStep - expectedStep;
if (skipOffset > 0) {
const skippedSteps: number[] = [];
for (let i = expectedStep; i < actualStep; i++) {
skippedSteps.push(i);
}
this.log('warn', 'Wizard auto-skip detected', {
expectedStep,
actualStep,
skipOffset,
skippedSteps,
});
return skipOffset;
}
return 0;
}
async getCurrentStep(): Promise<number | null> {
const page = this.getPage();
if (!page) {
return null;
}
if (this.isRealMode()) {
return null;
}
const stepAttr = await page.getAttribute('body', 'data-step');
return stepAttr ? parseInt(stepAttr, 10) : null;
}
private async isWizardModalDismissedInternal(): Promise<boolean> {
const page = this.getPage();
if (!page || !this.isRealMode()) {
return false;
}
try {
const stepContainerSelectors = Object.values(IRACING_SELECTORS.wizard.stepContainers);
for (const containerSelector of stepContainerSelectors) {
const count = await page.locator(containerSelector).count();
if (count > 0) {
this.log('debug', 'Wizard step container attached, wizard is active', { containerSelector });
return false;
}
}
const modalSelector = '#create-race-modal, [role="dialog"], .modal.fade';
const modalExists = (await page.locator(modalSelector).count()) > 0;
if (!modalExists) {
this.log('debug', 'No wizard modal element found - dismissed');
return true;
}
this.log('debug', 'Wizard step containers not attached, waiting 1000ms to confirm dismissal vs transition');
await page.waitForTimeout(1000);
for (const containerSelector of stepContainerSelectors) {
const count = await page.locator(containerSelector).count();
if (count > 0) {
this.log('debug', 'Wizard step container attached after delay - was just transitioning', {
containerSelector,
});
return false;
}
}
this.log('info', 'No wizard step containers attached after delay - confirmed dismissed by user');
return true;
} catch {
return false;
}
}
async checkWizardDismissed(currentStep: number): Promise<void> {
if (!this.isRealMode() || currentStep < 3) {
return;
}
if (await this.isWizardModalDismissedInternal()) {
this.log('info', 'Race creation wizard was dismissed by user');
if (this.onWizardDismissed) {
await this.onWizardDismissed().catch(() => {});
}
throw new Error('WIZARD_DISMISSED: User closed the race creation wizard');
}
}
}

View File

@@ -0,0 +1,247 @@
/**
* Selectors for the real iRacing website (members.iracing.com)
* Uses text-based and ARIA selectors since the site uses React/Chakra UI
* with dynamically generated class names.
*
* VERIFIED against html-dumps-optimized 2025-11-27
*/
export const IRACING_SELECTORS = {
// Login page
login: {
emailInput: '#username, input[name="username"], input[type="email"]',
passwordInput: '#password, input[type="password"]',
submitButton: 'button[type="submit"], button:has-text("Sign In")',
},
// Hosted Racing page (Step 1/2)
hostedRacing: {
createRaceButton:
'button:has-text("Create a Race"), button[aria-label="Create a Race"], button.chakra-button:has-text("Create a Race")',
hostedTab: 'a:has-text("Hosted")',
createRaceModal:
'#confirm-create-race-modal-modal-content, ' +
'#create-race-modal-modal-content, ' +
'#confirm-create-race-modal, ' +
'#create-race-modal, ' +
'#modal-children-container, ' +
'.modal-content',
newRaceButton:
'#confirm-create-race-modal-modal-content a.btn.btn-lg:has-text("New Race"), ' +
'#create-race-modal-modal-content a.btn.btn-lg:has-text("New Race"), ' +
'a.btn.btn-lg:has-text("New Race"), ' +
'a.btn.btn-info:has-text("New Race"), ' +
'.dropdown-menu a.dropdown-item.text-danger:has-text("New Race"), ' +
'.dropdown-menu a.dropdown-item:has-text("New Race"), ' +
'button.chakra-button:has-text("New Race")',
lastSettingsButton:
'#confirm-create-race-modal-modal-content a.btn.btn-lg:has-text("Last Settings"), ' +
'#create-race-modal-modal-content a.btn.btn-lg:has-text("Last Settings"), ' +
'a.btn.btn-lg:has-text("Last Settings"), ' +
'a.btn.btn-info:has-text("Last Settings")',
},
// Common modal/wizard selectors - VERIFIED from real HTML
wizard: {
modal: '#create-race-modal, .modal.fade.in',
modalDialog: '#create-race-modal-modal-dialog, .modal-dialog',
modalContent: '#create-race-modal-modal-content, .modal-content',
modalTitle: '[data-testid="modal-title"]',
// Wizard footer buttons (fixture + live)
// Primary navigation uses sidebar; footer has Back/Next-style step links.
nextButton:
'.wizard-footer .btn-group.pull-xs-left a.btn.btn-sm:last-child, ' +
'.wizard-footer .btn-group a.btn.btn-sm:last-child, ' +
'.modal-footer .btn-toolbar a.btn:not(.dropdown-toggle), ' +
'.modal-footer .btn-group a.btn:last-child',
backButton:
'.wizard-footer .btn-group.pull-xs-left a.btn.btn-sm:first-child, ' +
'.wizard-footer .btn-group a.btn.btn-sm:first-child, ' +
'.modal-footer .btn-group a.btn:first-child',
// Modal footer actions
confirmButton: '.modal-footer a.btn-success, .modal-footer button:has-text("Confirm"), button:has-text("OK")',
cancelButton: '.modal-footer a.btn-secondary, button:has-text("Cancel")',
closeButton: '[data-testid="button-close-modal"]',
// Wizard sidebar navigation links (use real sidebar IDs so text is present)
sidebarLinks: {
raceInformation: '#wizard-sidebar-link-set-session-information',
serverDetails: '#wizard-sidebar-link-set-server-details',
admins: '#wizard-sidebar-link-set-admins',
timeLimit: '#wizard-sidebar-link-set-time-limit',
cars: '#wizard-sidebar-link-set-cars',
track: '#wizard-sidebar-link-set-track',
trackOptions: '#wizard-sidebar-link-set-track-options',
timeOfDay: '#wizard-sidebar-link-set-time-of-day',
weather: '#wizard-sidebar-link-set-weather',
raceOptions: '#wizard-sidebar-link-set-race-options',
trackConditions: '#wizard-sidebar-link-set-track-conditions',
},
// Wizard step containers (the visible step content)
stepContainers: {
raceInformation: '#set-session-information',
serverDetails: '#set-server-details',
admins: '#set-admins',
timeLimit: '#set-time-limit',
cars: '#set-cars',
track: '#set-track',
trackOptions: '#set-track-options',
timeOfDay: '#set-time-of-day',
weather: '#set-weather',
raceOptions: '#set-race-options',
trackConditions: '#set-track-conditions',
},
},
// Form fields - based on actual iRacing DOM structure
fields: {
textInput: '.chakra-input, input.form-control, input[type="text"], input[data-field], input[data-test], input[placeholder]',
passwordInput: 'input[type="password"], input[maxlength="32"].form-control, input[data-field="password"], input[name="password"]',
textarea: 'textarea.form-control, .chakra-textarea, textarea, textarea[data-field]',
select: '.chakra-select, select.form-control, select, [data-dropdown], select[data-field]',
checkbox: '.chakra-checkbox, input[type="checkbox"], .switch-checkbox, input[data-toggle], [data-toggle]',
slider: '.chakra-slider, .slider, input[type="range"]',
toggle: '.switch input.switch-checkbox, .toggle-switch input, input[data-toggle]',
},
// Step-specific selectors - VERIFIED from real iRacing HTML structure
steps: {
// Step 3: Race Information - CORRECTED based on actual HTML structure
// Session name is a text input in a form-group with label "Session Name"
sessionName: '#set-session-information input.form-control[type="text"]:not([maxlength])',
sessionNameAlt: 'input[name="sessionName"], input.form-control[type="text"]',
// Password field has maxlength="32" and is a text input (not type="password")
password: '#set-session-information input.form-control[maxlength="32"]',
passwordAlt: 'input[maxlength="32"][type="text"]',
// Description is a textarea in the form
description: '#set-session-information textarea.form-control',
descriptionAlt: 'textarea.form-control',
// League racing toggle in Step 3
leagueRacingToggle: '#set-session-information .switch-checkbox, [data-toggle="leagueRacing"]',
// Step 4: Server Details
region:
'#set-server-details select.form-control, ' +
'#set-server-details [data-dropdown="region"], ' +
'#set-server-details [data-dropdown], ' +
'[data-dropdown="region"], ' +
'#set-server-details [role="radiogroup"] input[type="radio"]',
startNow:
'#set-server-details .switch-checkbox, ' +
'#set-server-details input[type="checkbox"], ' +
'[data-toggle="startNow"], ' +
'input[data-toggle="startNow"]',
// Step 5/6: Admins
adminSearch: 'input[placeholder*="Search"]',
adminList: '#set-admins table.table.table-striped, #set-admins .card-block table',
addAdminButton: 'a.btn:has-text("Add an Admin")',
// Step 7: Time Limits - Bootstrap-slider uses hidden input[type="text"] with dynamic id
// Fixtures show ids like time-limit-slider1764248520320
practice: '#set-time-limit input[id*="time-limit-slider"]',
qualify: '#set-time-limit input[id*="time-limit-slider"]',
race: '#set-time-limit input[id*="time-limit-slider"]',
// Step 8/9: Cars
carSearch:
'#select-car-set-cars input[placeholder*="Search"], ' +
'input[placeholder*="Search"]',
carList: '#select-car-set-cars table.table.table-striped, table.table.table-striped',
addCarButton:
'#select-car-set-cars a.btn.btn-primary:has-text("Add a Car"), ' +
'#select-car-set-cars a.btn.btn-primary:has-text("Add a Car 16 Available")',
addCarModal:
'#select-car-compact-content, ' +
'.drawer-container, ' +
'.drawer-container .drawer',
carSelectButton:
'#select-car-set-cars a.btn.btn-block:has-text("Select"), ' +
'a.btn.btn-block:has-text("Select")',
// Step 10/11/12: Track
trackSearch: 'input[placeholder*="Search"]',
trackList: 'table.table.table-striped',
// Add Track button - CORRECTED: Uses specific class and text
addTrackButton: 'a.btn.btn-primary.btn-block.btn-sm:has-text("Add a Track")',
// Track selection interface - drawer that opens within the card
addTrackModal: '.drawer-container .drawer',
// Select button inside track dropdown - opens config selection
trackSelectButton: 'a.btn.btn-primary.btn-xs.dropdown-toggle:has-text("Select")',
// Dropdown toggle for multi-config tracks - opens a menu of track configurations
trackSelectDropdown: 'a.btn.btn-primary.btn-xs.dropdown-toggle',
// First item in the dropdown menu for selecting track configuration
trackSelectDropdownItem: '.dropdown-menu.dropdown-menu-right .dropdown-item:first-child',
// Step 13: Track Options
trackConfig: '#set-track-options select.form-control, #set-track-options [data-dropdown="trackConfig"]',
// Step 14: Time of Day - iRacing uses datetime picker (rdt class) and Bootstrap-slider components
// The datetime picker has input.form-control, sliders have hidden input[type="text"]
timeOfDay: '#set-time-of-day .rdt input.form-control, #set-time-of-day input[id*="slider"], #set-time-of-day .slider input[type="text"], #set-time-of-day [data-slider="timeOfDay"]',
// Step 15: Weather
weatherType: '#set-weather select.form-control, #set-weather [data-dropdown="weatherType"]',
// Temperature slider uses Bootstrap-slider with hidden input[type="text"]
temperature: '#set-weather input[id*="slider"], #set-weather .slider input[type="text"], #set-weather [data-slider="temperature"]',
// Step 16: Race Options
maxDrivers: '#set-race-options input[name*="maxDrivers"], #set-race-options input[type="number"]',
rollingStart: '#set-race-options .switch-checkbox[name*="rolling"], #set-race-options input[type="checkbox"]',
// Step 17: Track Conditions (final step)
trackState: '#set-track-conditions select.form-control, #set-track-conditions [data-dropdown="trackState"]',
},
/**
* DANGER ZONE - Selectors for checkout/payment buttons that should NEVER be clicked.
* The automation must block any click on these selectors to prevent accidental purchases.
* VERIFIED from real iRacing HTML - the checkout button has class btn-success with icon-cart
*/
BLOCKED_SELECTORS: {
// Checkout/payment buttons - NEVER click these (verified from real HTML)
checkout: '.chakra-button:has-text("Check Out"), a.btn-success:has(.icon-cart), a.btn:has-text("Check Out"), button:has-text("Check Out"), [data-testid*="checkout"]',
purchase: 'button:has-text("Purchase"), a.btn:has-text("Purchase"), .chakra-button:has-text("Purchase"), button[aria-label="Purchase"]',
buy: 'button:has-text("Buy"), a.btn:has-text("Buy Now"), button:has-text("Buy Now")',
payment: 'button[type="submit"]:has-text("Submit Payment"), .payment-button, #checkout-button, button:has-text("Pay"), a.btn:has-text("Pay")',
cart: 'a.btn:has(.icon-cart), button:has(.icon-cart), .btn-success:has(.icon-cart)',
// Price labels that indicate purchase actions (e.g., "$0.50")
priceAction: 'a.btn:has(.label-pill:has-text("$")), button:has(.label-pill:has-text("$")), .btn:has(.label-inverse:has-text("$"))',
},
} as const;
/**
* Combined selector for all blocked/dangerous elements.
* Use this to check if any selector targets a payment button.
*/
export const ALL_BLOCKED_SELECTORS = Object.values(IRACING_SELECTORS.BLOCKED_SELECTORS).join(', ');
/**
* Keywords that indicate a dangerous/checkout action.
* Used for text-based safety checks.
*/
export const BLOCKED_KEYWORDS = [
'checkout',
'check out',
'purchase',
'buy now',
'buy',
'pay',
'submit payment',
'add to cart',
'proceed to payment',
] as const;
export const IRACING_URLS = {
hostedSessions: 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
login: 'https://members.iracing.com/membersite/login.jsp',
home: 'https://members.iracing.com',
} as const;
/**
* Timeout values for real iRacing automation (in milliseconds)
*/
export const IRACING_TIMEOUTS = {
navigation: 30000,
elementWait: 15000,
loginWait: 120000, // 2 minutes for manual login
pageLoad: 20000,
} as const;

View File

@@ -0,0 +1,431 @@
import type { Page } from 'playwright';
import type { ILogger } from '../../../../application/ports/ILogger';
import { IRACING_SELECTORS, BLOCKED_KEYWORDS } from './IRacingSelectors';
import type { PlaywrightConfig } from '../core/PlaywrightAutomationAdapter';
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
export class SafeClickService {
constructor(
private readonly config: Required<PlaywrightConfig>,
private readonly browserSession: PlaywrightBrowserSession,
private readonly logger?: ILogger,
) {}
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
if (!this.logger) {
return;
}
const logger: any = this.logger;
logger[level](message, context as any);
}
private isRealMode(): boolean {
return this.config.mode === 'real';
}
private getPage(): Page {
const page = this.browserSession.getPage();
if (!page) {
throw new Error('Browser not connected');
}
return page;
}
/**
* Check if a selector or element text matches blocked patterns (checkout/payment buttons).
* SAFETY CRITICAL: This prevents accidental purchases during automation.
*
* @param selector The CSS selector being clicked
* @param elementText Optional text content of the element (should be direct text only)
* @returns true if the selector/text matches a blocked pattern
*/
private isBlockedSelector(selector: string, elementText?: string): boolean {
const selectorLower = selector.toLowerCase();
const textLower = elementText?.toLowerCase().trim() ?? '';
// Check if selector contains any blocked keywords
for (const keyword of BLOCKED_KEYWORDS) {
if (selectorLower.includes(keyword) || textLower.includes(keyword)) {
return true;
}
}
// Check for price indicators (e.g., "$0.50", "$19.99")
// IMPORTANT: Only block if the price is combined with a checkout-related action word
// This prevents false positives when price is merely displayed on the page
const pricePattern = /\$\d+\.\d{2}/;
const hasPrice = pricePattern.test(textLower) || pricePattern.test(selector);
if (hasPrice) {
// Only block if text also contains checkout-related words
const checkoutActionWords = ['check', 'out', 'buy', 'purchase', 'pay', 'cart'];
const hasCheckoutWord = checkoutActionWords.some(word => textLower.includes(word));
if (hasCheckoutWord) {
return true;
}
}
// Check for cart icon class
if (selectorLower.includes('icon-cart') || selectorLower.includes('cart-icon')) {
return true;
}
return false;
}
/**
* Verify an element is not a blocked checkout/payment button before clicking.
* SAFETY CRITICAL: Throws error if element matches blocked patterns.
*
* This method checks:
* 1. The selector string itself for blocked patterns
* 2. The element's DIRECT text content (not children/siblings)
* 3. The element's class, id, and href attributes for checkout indicators
* 4. Whether the element matches any blocked CSS selectors
*
* @param selector The CSS selector of the element to verify
* @throws Error if element is a blocked checkout/payment button
*/
async verifyNotBlockedElement(selector: string): Promise<void> {
const page = this.browserSession.getPage();
if (!page) return;
// In mock mode we bypass safety blocking to allow tests to exercise checkout flows
// without risking real-world purchases. Safety checks remain active in 'real' mode.
if (!this.isRealMode()) {
this.log('debug', 'Mock mode detected - skipping checkout blocking checks', { selector });
return;
}
// First check the selector itself
if (this.isBlockedSelector(selector)) {
const errorMsg = `🚫 BLOCKED: Selector "${selector}" matches checkout/payment pattern. Automation stopped for safety.`;
this.log('error', errorMsg);
throw new Error(errorMsg);
}
// Try to get the element's attributes and direct text for verification
try {
const element = page.locator(selector).first();
const isVisible = await element.isVisible().catch(() => false);
if (isVisible) {
// Get element attributes for checking
const elementClass = (await element.getAttribute('class').catch(() => '')) ?? '';
const elementId = (await element.getAttribute('id').catch(() => '')) ?? '';
const elementHref = (await element.getAttribute('href').catch(() => '')) ?? '';
// Check class/id/href for checkout indicators
const attributeText = `${elementClass} ${elementId} ${elementHref}`.toLowerCase();
if (
attributeText.includes('checkout') ||
attributeText.includes('cart') ||
attributeText.includes('purchase') ||
attributeText.includes('payment')
) {
const errorMsg = `🚫 BLOCKED: Element attributes contain checkout pattern. Class="${elementClass}", ID="${elementId}", Href="${elementHref}". Automation stopped for safety.`;
this.log('error', errorMsg);
throw new Error(errorMsg);
}
// Get ONLY the direct text of this element, excluding child element text
// This prevents false positives when a checkout button exists elsewhere on the page
const directText = await element
.evaluate((el) => {
let text = '';
const childNodes = Array.from(el.childNodes);
for (let i = 0; i < childNodes.length; i++) {
const node = childNodes[i];
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent || '';
}
}
return text.trim();
})
.catch(() => '');
// Also get innerText as fallback (for buttons with icon + text structure)
// But only check if directText is empty or very short
let textToCheck = directText;
if (directText.length < 3) {
const innerText = await element.innerText().catch(() => '');
if (innerText.length < 100) {
textToCheck = innerText.trim();
}
}
this.log('debug', 'Checking element text for blocked patterns', {
selector,
directText,
textToCheck,
elementClass,
});
if (textToCheck && this.isBlockedSelector('', textToCheck)) {
const errorMsg = `🚫 BLOCKED: Element text "${textToCheck}" matches checkout/payment pattern. Automation stopped for safety.`;
this.log('error', errorMsg);
throw new Error(errorMsg);
}
// Check if element matches any of the blocked selectors directly
for (const blockedSelector of Object.values(IRACING_SELECTORS.BLOCKED_SELECTORS)) {
const matchesBlocked = await element
.evaluate((el, sel) => {
try {
return el.matches(sel) || el.closest(sel) !== null;
} catch {
return false;
}
}, blockedSelector)
.catch(() => false);
if (matchesBlocked) {
const errorMsg = `🚫 BLOCKED: Element matches blocked selector "${blockedSelector}". Automation stopped for safety.`;
this.log('error', errorMsg);
throw new Error(errorMsg);
}
}
}
} catch (error) {
if (error instanceof Error && error.message.includes('BLOCKED')) {
throw error;
}
this.log('debug', 'Could not verify element (may not exist yet)', { selector, error: String(error) });
}
}
/**
* Dismiss any visible Chakra UI modal popups that might block interactions.
* This handles various modal dismiss patterns including close buttons and overlay clicks.
* Optimized for speed - uses instant visibility checks and minimal waits.
*/
async dismissModals(): Promise<void> {
const page = this.browserSession.getPage();
if (!page) return;
try {
const modalContainer = page.locator('.chakra-modal__content-container, .modal-content');
const isModalVisible = await modalContainer.isVisible().catch(() => false);
if (!isModalVisible) {
this.log('debug', 'No modal visible, continuing');
return;
}
this.log('info', 'Modal detected, dismissing immediately');
const dismissButton = page
.locator(
'.chakra-modal__content-container button[aria-label="Continue"], ' +
'.chakra-modal__content-container button:has-text("Continue"), ' +
'.chakra-modal__content-container button:has-text("Close"), ' +
'.chakra-modal__content-container button:has-text("OK"), ' +
'.chakra-modal__close-btn, ' +
'[aria-label="Close"]',
)
.first();
if (await dismissButton.isVisible().catch(() => false)) {
this.log('info', 'Clicking modal dismiss button');
await dismissButton.click({ force: true, timeout: 1000 });
await page.waitForTimeout(100);
return;
}
this.log('debug', 'No dismiss button found, skipping Escape to avoid closing wizard');
await page.waitForTimeout(100);
} catch (error) {
this.log('debug', 'Modal dismiss error (non-critical)', { error: String(error) });
}
}
/**
* Dismiss any open React DateTime pickers (rdt component).
* These pickers can intercept pointer events and block clicks on other elements.
* Used specifically before navigating away from steps that have datetime pickers.
*
* IMPORTANT: Do NOT use Escape key as it closes the entire wizard modal in iRacing.
*/
async dismissDatetimePickers(): Promise<void> {
const page = this.browserSession.getPage();
if (!page) return;
try {
const initialCount = await page.locator('.rdt.rdtOpen').count();
if (initialCount === 0) {
this.log('debug', 'No datetime picker open');
return;
}
this.log('info', `Closing ${initialCount} open datetime picker(s)`);
// Strategy 1: remove rdtOpen class via JS
await page.evaluate(() => {
const openPickers = document.querySelectorAll('.rdt.rdtOpen');
openPickers.forEach((picker) => {
picker.classList.remove('rdtOpen');
});
const activeEl = document.activeElement as HTMLElement;
if (activeEl && activeEl.blur && activeEl.closest('.rdt')) {
activeEl.blur();
}
});
await page.waitForTimeout(50);
let stillOpenCount = await page.locator('.rdt.rdtOpen').count();
if (stillOpenCount === 0) {
this.log('debug', 'Datetime pickers closed via JavaScript');
return;
}
// Strategy 2: click outside
this.log('debug', `${stillOpenCount} picker(s) still open, clicking outside`);
const modalBody = page.locator(IRACING_SELECTORS.wizard.modalContent).first();
if (await modalBody.isVisible().catch(() => false)) {
const cardHeader = page.locator(`${IRACING_SELECTORS.wizard.stepContainers.timeOfDay} .card-header`).first();
if (await cardHeader.isVisible().catch(() => false)) {
await cardHeader.click({ force: true, timeout: 1000 }).catch(() => {});
await page.waitForTimeout(100);
}
}
stillOpenCount = await page.locator('.rdt.rdtOpen').count();
if (stillOpenCount === 0) {
this.log('debug', 'Datetime pickers closed via click outside');
return;
}
// Strategy 3: blur inputs and force-remove rdtOpen
this.log('debug', `${stillOpenCount} picker(s) still open, force blur`);
await page.evaluate(() => {
const rdtInputs = document.querySelectorAll('.rdt input');
rdtInputs.forEach((input) => {
(input as HTMLElement).blur();
});
const openPickers = document.querySelectorAll('.rdt.rdtOpen');
openPickers.forEach((picker) => {
picker.classList.remove('rdtOpen');
const pickerDropdown = picker.querySelector('.rdtPicker') as HTMLElement;
if (pickerDropdown) {
pickerDropdown.style.display = 'none';
}
});
});
await page.waitForTimeout(50);
const finalCount = await page.locator('.rdt.rdtOpen').count();
if (finalCount > 0) {
this.log('warn', `Could not close ${finalCount} datetime picker(s), will attempt click with force`);
} else {
this.log('debug', 'Datetime picker dismiss complete');
}
} catch (error) {
this.log('debug', 'Datetime picker dismiss error (non-critical)', { error: String(error) });
}
}
/**
* Safe click wrapper that handles modal interception errors with auto-retry.
* If a click fails because a modal is intercepting pointer events, this method
* will dismiss the modal and retry the click operation.
*
* SAFETY: Before any click, verifies the target is not a checkout/payment button.
*
* @param selector The CSS selector of the element to click
* @param options Click options including timeout and force
* @returns Promise that resolves when click succeeds or throws after max retries
*/
async safeClick(
selector: string,
options?: { timeout?: number; force?: boolean },
): Promise<void> {
const page = this.getPage();
// In mock mode, ensure mock fixtures are visible (remove 'hidden' flags)
if (!this.isRealMode()) {
try {
await page.evaluate(() => {
document
.querySelectorAll<HTMLElement>('.wizard-step.hidden, .modal.hidden, .wizard-step[hidden]')
.forEach((el) => {
el.classList.remove('hidden');
el.removeAttribute('hidden');
});
});
} catch {
// ignore any evaluation errors in test environments
}
}
// SAFETY CHECK: Verify this is not a checkout/payment button
await this.verifyNotBlockedElement(selector);
const maxRetries = 3;
const timeout = options?.timeout ?? this.config.timeout;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const useForce = options?.force || attempt === maxRetries;
await page.click(selector, { timeout, force: useForce });
return;
} catch (error) {
if (error instanceof Error && error.message.includes('BLOCKED')) {
throw error;
}
const errorMessage = String(error);
if (
errorMessage.includes('intercepts pointer events') ||
errorMessage.includes('chakra-modal') ||
errorMessage.includes('chakra-portal') ||
errorMessage.includes('rdtDay') ||
errorMessage.includes('rdtPicker') ||
errorMessage.includes('rdt')
) {
this.log('info', `Element intercepting click (attempt ${attempt}/${maxRetries}), dismissing...`, {
selector,
attempt,
maxRetries,
});
await this.dismissDatetimePickers();
await this.dismissModals();
await page.waitForTimeout(200);
if (attempt === maxRetries) {
this.log('warn', 'Max retries reached, attempting JS click fallback', { selector });
try {
const clicked = await page.evaluate((sel) => {
try {
const el = document.querySelector(sel) as HTMLElement | null;
if (!el) return false;
el.scrollIntoView({ block: 'center', inline: 'center' });
el.click();
return true;
} catch {
return false;
}
}, selector);
if (clicked) {
this.log('info', 'JS fallback click succeeded', { selector });
return;
} else {
this.log('debug', 'JS fallback click did not find element or failed', { selector });
}
} catch (e) {
this.log('debug', 'JS fallback click error', { selector, error: String(e) });
}
this.log('error', 'Max retries reached, click still blocked', { selector });
throw error;
}
} else {
throw error;
}
}
}
}
}

View File

@@ -0,0 +1,152 @@
import { IAutomationEngine, ValidationResult } from '../../../../application/ports/IAutomationEngine';
import { HostedSessionConfig } from '@gridpilot/automation/domain/entities/HostedSessionConfig';
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation';
import { ISessionRepository } from '../../../../application/ports/ISessionRepository';
import { StepTransitionValidator } from '@gridpilot/automation/domain/services/StepTransitionValidator';
/**
* Real Automation Engine Adapter.
*
* Orchestrates the automation workflow by:
* 1. Validating session configuration
* 2. Executing each step using real browser automation
* 3. Managing session state transitions
*
* This is a REAL implementation that uses actual automation,
* not a mock. Historically delegated to legacy native screen
* automation adapters, but those are no longer part of the
* supported stack.
*
* @deprecated This adapter should be updated to use Playwright
* browser automation when available. See docs/ARCHITECTURE.md
* for the updated automation strategy.
*/
export class AutomationEngineAdapter implements IAutomationEngine {
private isRunning = false;
private automationPromise: Promise<void> | null = null;
constructor(
private readonly browserAutomation: IBrowserAutomation,
private readonly sessionRepository: ISessionRepository
) {}
async validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult> {
if (!config.sessionName || config.sessionName.trim() === '') {
return { isValid: false, error: 'Session name is required' };
}
if (!config.trackId || config.trackId.trim() === '') {
return { isValid: false, error: 'Track ID is required' };
}
if (!config.carIds || config.carIds.length === 0) {
return { isValid: false, error: 'At least one car must be selected' };
}
return { isValid: true };
}
async executeStep(stepId: StepId, config: HostedSessionConfig): Promise<void> {
const sessions = await this.sessionRepository.findAll();
const session = sessions[0];
if (!session) {
throw new Error('No active session found');
}
// Start session if it's at step 1 and pending
if (session.state.isPending() && stepId.value === 1) {
session.start();
await this.sessionRepository.update(session);
// Start automated progression
this.startAutomation(config);
}
}
private startAutomation(config: HostedSessionConfig): void {
if (this.isRunning) {
return;
}
this.isRunning = true;
this.automationPromise = this.runAutomationLoop(config);
}
private async runAutomationLoop(config: HostedSessionConfig): Promise<void> {
while (this.isRunning) {
try {
const sessions = await this.sessionRepository.findAll();
const session = sessions[0];
if (!session || !session.state.isInProgress()) {
this.isRunning = false;
return;
}
const currentStep = session.currentStep;
// Execute current step using the browser automation
if (this.browserAutomation.executeStep) {
const result = await this.browserAutomation.executeStep(currentStep, config as unknown as Record<string, unknown>);
if (!result.success) {
const stepDescription = StepTransitionValidator.getStepDescription(currentStep);
const errorMessage = `Step ${currentStep.value} (${stepDescription}) failed: ${result.error}`;
console.error(errorMessage);
// Stop automation and mark session as failed
this.isRunning = false;
session.fail(errorMessage);
await this.sessionRepository.update(session);
return;
}
} else {
// Fallback for adapters without executeStep
await this.browserAutomation.navigateToPage(`step-${currentStep.value}`);
}
// Transition to next step
if (!currentStep.isFinalStep()) {
session.transitionToStep(currentStep.next());
await this.sessionRepository.update(session);
// If we just transitioned to the final step, execute it before stopping
const nextStep = session.currentStep;
if (nextStep.isFinalStep()) {
// Execute final step handler
if (this.browserAutomation.executeStep) {
const result = await this.browserAutomation.executeStep(nextStep, config as unknown as Record<string, unknown>);
if (!result.success) {
const stepDescription = StepTransitionValidator.getStepDescription(nextStep);
const errorMessage = `Step ${nextStep.value} (${stepDescription}) failed: ${result.error}`;
console.error(errorMessage);
// Don't try to fail terminal session - just log the error
// Session is already in STOPPED_AT_STEP_18 state after transitionToStep()
}
}
// Stop after final step
this.isRunning = false;
return;
}
} else {
// Current step is already final - stop
this.isRunning = false;
return;
}
// Wait before next iteration
await this.delay(500);
} catch (error) {
console.error('Automation error:', error);
this.isRunning = false;
return;
}
}
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
public stopAutomation(): void {
this.isRunning = false;
this.automationPromise = null;
}
}

View File

@@ -0,0 +1,175 @@
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
export interface IFixtureServer {
start(port?: number): Promise<{ url: string; port: number }>;
stop(): Promise<void>;
getFixtureUrl(stepNumber: number): string;
isRunning(): boolean;
}
/**
* Step number to fixture file mapping.
* Steps 1-18 map to the corresponding HTML fixture files.
*/
const STEP_TO_FIXTURE: Record<number, string> = {
1: '01-hosted-racing.html',
2: '02-create-a-race.html',
3: '03-race-information.html',
4: '04-server-details.html',
5: '05-set-admins.html',
6: '06-add-an-admin.html',
7: '07-time-limits.html',
8: '08-set-cars.html',
9: '09-add-a-car.html',
10: '10-set-car-classes.html',
11: '11-set-track.html',
12: '12-add-a-track.html',
13: '13-track-options.html',
14: '14-time-of-day.html',
15: '15-weather.html',
16: '16-race-options.html',
17: '17-team-driving.html',
18: '18-track-conditions.html',
};
export class FixtureServer implements IFixtureServer {
private server: http.Server | null = null;
private port: number = 3456;
private fixturesPath: string;
constructor(fixturesPath?: string) {
this.fixturesPath =
fixturesPath ?? path.resolve(process.cwd(), 'html-dumps/iracing-hosted-sessions');
}
async start(port: number = 3456): Promise<{ url: string; port: number }> {
if (this.server) {
return { url: `http://localhost:${this.port}`, port: this.port };
}
this.port = port;
return new Promise((resolve, reject) => {
this.server = http.createServer((req, res) => {
this.handleRequest(req, res);
});
this.server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
this.server = null;
this.start(port + 1).then(resolve).catch(reject);
} else {
reject(err);
}
});
this.server.listen(this.port, () => {
resolve({ url: `http://localhost:${this.port}`, port: this.port });
});
});
}
async stop(): Promise<void> {
if (!this.server) {
return;
}
return new Promise((resolve, reject) => {
this.server!.close((err) => {
if (err) {
reject(err);
} else {
this.server = null;
resolve();
}
});
});
}
getFixtureUrl(stepNumber: number): string {
const fixture = STEP_TO_FIXTURE[stepNumber];
if (!fixture) {
return `http://localhost:${this.port}/`;
}
return `http://localhost:${this.port}/${fixture}`;
}
isRunning(): boolean {
return this.server !== null;
}
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
const urlPath = req.url || '/';
let fileName: string;
if (urlPath === '/') {
fileName = STEP_TO_FIXTURE[1];
} else {
fileName = urlPath.replace(/^\//, '');
const legacyMatch = fileName.match(/^step-(\d+)-/);
if (legacyMatch) {
const stepNum = Number(legacyMatch[1]);
const mapped = STEP_TO_FIXTURE[stepNum];
if (mapped) {
fileName = mapped;
}
}
}
const filePath = path.join(this.fixturesPath, fileName);
// Security check - prevent directory traversal
if (!filePath.startsWith(this.fixturesPath)) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Forbidden');
return;
}
fs.readFile(filePath, (err, data) => {
if (err) {
const errno = (err as NodeJS.ErrnoException).code;
if (errno === 'ENOENT') {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
} else {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
}
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentTypes: Record<string, string> = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
};
const contentType = contentTypes[ext] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
});
}
}
/**
* Get the fixture filename for a given step number.
*/
export function getFixtureForStep(stepNumber: number): string | undefined {
return STEP_TO_FIXTURE[stepNumber];
}
/**
* Get all step-to-fixture mappings.
*/
export function getAllStepFixtureMappings(): Record<number, string> {
return { ...STEP_TO_FIXTURE };
}

View File

@@ -0,0 +1,154 @@
import { IAutomationEngine, ValidationResult } from '../../../../application/ports/IAutomationEngine';
import { HostedSessionConfig } from '@gridpilot/automation/domain/entities/HostedSessionConfig';
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation';
import { ISessionRepository } from '../../../../application/ports/ISessionRepository';
import { StepTransitionValidator } from '@gridpilot/automation/domain/services/StepTransitionValidator';
export class MockAutomationEngineAdapter implements IAutomationEngine {
private isRunning = false;
private automationPromise: Promise<void> | null = null;
constructor(
private readonly browserAutomation: IBrowserAutomation,
private readonly sessionRepository: ISessionRepository
) {}
async validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult> {
if (!config.sessionName || config.sessionName.trim() === '') {
return { isValid: false, error: 'Session name is required' };
}
if (!config.trackId || config.trackId.trim() === '') {
return { isValid: false, error: 'Track ID is required' };
}
if (!config.carIds || config.carIds.length === 0) {
return { isValid: false, error: 'At least one car must be selected' };
}
return { isValid: true };
}
async executeStep(stepId: StepId, config: HostedSessionConfig): Promise<void> {
const sessions = await this.sessionRepository.findAll();
const session = sessions[0];
if (!session) {
throw new Error('No active session found');
}
// Start session if it's at step 1 and pending
if (session.state.isPending() && stepId.value === 1) {
session.start();
await this.sessionRepository.update(session);
// Start automated progression
this.startAutomation(config);
}
}
private startAutomation(config: HostedSessionConfig): void {
if (this.isRunning) {
return;
}
this.isRunning = true;
this.automationPromise = this.runAutomationLoop(config);
}
private async runAutomationLoop(config: HostedSessionConfig): Promise<void> {
while (this.isRunning) {
try {
const sessions = await this.sessionRepository.findAll();
const session = sessions[0];
if (!session || !session.state.isInProgress()) {
this.isRunning = false;
return;
}
const currentStep = session.currentStep;
// Execute current step using the browser automation
if (this.browserAutomation.executeStep) {
const result = await this.browserAutomation.executeStep(
currentStep,
config as unknown as Record<string, unknown>,
);
if (!result.success) {
const stepDescription = StepTransitionValidator.getStepDescription(currentStep);
const errorMessage = `Step ${currentStep.value} (${stepDescription}) failed: ${result.error}`;
console.error(errorMessage);
// Stop automation and mark session as failed
this.isRunning = false;
session.fail(errorMessage);
await this.sessionRepository.update(session);
return;
}
} else {
// Fallback for adapters without executeStep (e.g., MockBrowserAutomationAdapter)
await this.browserAutomation.navigateToPage(`step-${currentStep.value}`);
}
// Transition to next step
if (!currentStep.isFinalStep()) {
session.transitionToStep(currentStep.next());
await this.sessionRepository.update(session);
// If we just transitioned to the final step, execute it before stopping
const nextStep = session.currentStep;
if (nextStep.isFinalStep()) {
// Execute final step handler
if (this.browserAutomation.executeStep) {
const result = await this.browserAutomation.executeStep(
nextStep,
config as unknown as Record<string, unknown>,
);
if (!result.success) {
const stepDescription = StepTransitionValidator.getStepDescription(nextStep);
const errorMessage = `Step ${nextStep.value} (${stepDescription}) failed: ${result.error}`;
console.error(errorMessage);
// Don't try to fail terminal session - just log the error
// Session is already in STOPPED_AT_STEP_18 state after transitionToStep()
}
}
// Stop after final step
this.isRunning = false;
return;
}
} else {
// Current step is already final - stop
this.isRunning = false;
return;
}
// Wait before next iteration
await this.delay(500);
} catch (error) {
console.error('Automation error:', error);
this.isRunning = false;
try {
const sessions = await this.sessionRepository.findAll();
const session = sessions[0];
if (session && !session.state.isTerminal()) {
const message =
error instanceof Error ? error.message : String(error);
session.fail(`Automation error: ${message}`);
await this.sessionRepository.update(session);
}
} catch {
}
return;
}
}
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
public stopAutomation(): void {
this.isRunning = false;
this.automationPromise = null;
}
}

View File

@@ -0,0 +1,159 @@
import { StepId } from '@gridpilot/automation/domain/value-objects/StepId';
import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation';
import {
NavigationResult,
FormFillResult,
ClickResult,
WaitResult,
ModalResult,
AutomationResult,
} from '../../../../application/ports/AutomationResults';
interface MockConfig {
simulateFailures?: boolean;
failureRate?: number;
}
interface StepExecutionResult {
success: boolean;
stepId: number;
wasModalStep?: boolean;
shouldStop?: boolean;
executionTime: number;
metrics: {
totalDelay: number;
operationCount: number;
};
}
export class MockBrowserAutomationAdapter implements IBrowserAutomation {
private config: MockConfig;
private connected: boolean = false;
constructor(config: MockConfig = {}) {
this.config = {
simulateFailures: config.simulateFailures ?? false,
failureRate: config.failureRate ?? 0.1,
};
}
async connect(): Promise<AutomationResult> {
this.connected = true;
return { success: true };
}
async disconnect(): Promise<void> {
this.connected = false;
}
isConnected(): boolean {
return this.connected;
}
async navigateToPage(url: string): Promise<NavigationResult> {
const delay = this.randomDelay(200, 800);
await this.sleep(delay);
return {
success: true,
url,
loadTime: delay,
};
}
async fillFormField(fieldName: string, value: string): Promise<FormFillResult> {
const delay = this.randomDelay(100, 500);
await this.sleep(delay);
return {
success: true,
fieldName,
valueSet: value,
};
}
async clickElement(selector: string): Promise<ClickResult> {
const delay = this.randomDelay(50, 300);
await this.sleep(delay);
return {
success: true,
target: selector,
};
}
async waitForElement(selector: string, maxWaitMs: number = 5000): Promise<WaitResult> {
const delay = this.randomDelay(100, 1000);
await this.sleep(delay);
return {
success: true,
target: selector,
waitedMs: delay,
found: true,
};
}
async handleModal(stepId: StepId, action: string): Promise<ModalResult> {
if (!stepId.isModalStep()) {
throw new Error(`Step ${stepId.value} is not a modal step`);
}
const delay = this.randomDelay(200, 600);
await this.sleep(delay);
return {
success: true,
stepId: stepId.value,
action,
};
}
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
if (this.shouldSimulateFailure()) {
throw new Error(`Simulated failure at step ${stepId.value}`);
}
const startTime = Date.now();
let totalDelay = 0;
let operationCount = 0;
const navigationDelay = this.randomDelay(200, 500);
await this.sleep(navigationDelay);
totalDelay += navigationDelay;
operationCount++;
if (stepId.isModalStep()) {
const modalDelay = this.randomDelay(200, 400);
await this.sleep(modalDelay);
totalDelay += modalDelay;
operationCount++;
}
const executionTime = Date.now() - startTime;
return {
success: true,
metadata: {
stepId: stepId.value,
wasModalStep: stepId.isModalStep(),
shouldStop: stepId.isFinalStep(),
executionTime,
totalDelay,
operationCount,
},
};
}
private randomDelay(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
private async sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
private shouldSimulateFailure(): boolean {
if (!this.config.simulateFailures) {
return false;
}
return Math.random() < (this.config.failureRate || 0.1);
}
}

View File

@@ -0,0 +1,19 @@
/**
* Automation adapters for browser automation.
*
* Exports:
* - MockBrowserAutomationAdapter: Mock adapter for testing
* - PlaywrightAutomationAdapter: Browser automation via Playwright
* - FixtureServer: HTTP server for serving fixture HTML files
*/
// Adapters
export { MockBrowserAutomationAdapter } from './engine/MockBrowserAutomationAdapter';
export { PlaywrightAutomationAdapter } from './core/PlaywrightAutomationAdapter';
export type { PlaywrightConfig, AutomationAdapterMode } from './core/PlaywrightAutomationAdapter';
// Services
export { FixtureServer, getFixtureForStep, getAllStepFixtureMappings } from './engine/FixtureServer';
export type { IFixtureServer } from './engine/FixtureServer';
// Template map and utilities removed (image-based automation deprecated)

View File

@@ -0,0 +1,90 @@
/**
* ElectronCheckoutConfirmationAdapter
* Implements ICheckoutConfirmationPort using Electron IPC for main-renderer communication.
*/
import type { BrowserWindow } from 'electron';
import { ipcMain } from 'electron';
import { Result } from '../../../shared/result/Result';
import type { ICheckoutConfirmationPort, CheckoutConfirmationRequest } from '../../../application/ports/ICheckoutConfirmationPort';
import { CheckoutConfirmation } from '@gridpilot/automation/domain/value-objects/CheckoutConfirmation';
export class ElectronCheckoutConfirmationAdapter implements ICheckoutConfirmationPort {
private mainWindow: BrowserWindow;
private pendingConfirmation: {
resolve: (confirmation: CheckoutConfirmation) => void;
reject: (error: Error) => void;
timeoutId: NodeJS.Timeout;
} | null = null;
constructor(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow;
this.setupIpcHandlers();
}
private setupIpcHandlers(): void {
// Listen for confirmation response from renderer
ipcMain.on('checkout:confirm', (_event, decision: 'confirmed' | 'cancelled' | 'timeout') => {
if (!this.pendingConfirmation) {
return;
}
// Clear timeout
clearTimeout(this.pendingConfirmation.timeoutId);
// Create confirmation based on decision
const confirmation = CheckoutConfirmation.create(decision);
this.pendingConfirmation.resolve(confirmation);
this.pendingConfirmation = null;
});
}
async requestCheckoutConfirmation(
request: CheckoutConfirmationRequest
): Promise<Result<CheckoutConfirmation>> {
try {
// Only allow one pending confirmation at a time
if (this.pendingConfirmation) {
return Result.err(new Error('Confirmation already pending'));
}
// Send request to renderer
this.mainWindow.webContents.send('checkout:request-confirmation', {
price: request.price.toDisplayString(),
state: request.state.isReady() ? 'ready' : 'insufficient_funds',
sessionMetadata: request.sessionMetadata,
timeoutMs: request.timeoutMs,
});
// Wait for response with timeout
const confirmation = await new Promise<CheckoutConfirmation>((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pendingConfirmation = null;
const timeoutConfirmation = CheckoutConfirmation.create('timeout');
resolve(timeoutConfirmation);
}, request.timeoutMs);
this.pendingConfirmation = {
resolve,
reject,
timeoutId,
};
});
return Result.ok(confirmation);
} catch (error) {
this.pendingConfirmation = null;
return Result.err(
error instanceof Error ? error : new Error('Failed to request confirmation')
);
}
}
public cleanup(): void {
if (this.pendingConfirmation) {
clearTimeout(this.pendingConfirmation.timeoutId);
this.pendingConfirmation = null;
}
ipcMain.removeAllListeners('checkout:confirm');
}
}

View File

@@ -0,0 +1,19 @@
import type { ILogger, LogContext } from '../../../application/ports/ILogger';
export class NoOpLogAdapter implements ILogger {
debug(_message: string, _context?: LogContext): void {}
info(_message: string, _context?: LogContext): void {}
warn(_message: string, _context?: LogContext): void {}
error(_message: string, _error?: Error, _context?: LogContext): void {}
fatal(_message: string, _error?: Error, _context?: LogContext): void {}
child(_context: LogContext): ILogger {
return this;
}
async flush(): Promise<void> {}
}

View File

@@ -0,0 +1,116 @@
import type { ILogger, LogContext, LogLevel } from '../../../application/ports/ILogger';
import { loadLoggingConfig, type LoggingEnvironmentConfig } from '../../config/LoggingConfig';
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
fatal: 50,
};
/**
* PinoLogAdapter - Electron-compatible logger implementation.
*
* Note: We use a custom console-based implementation instead of pino
* because pino's internal use of diagnostics_channel.tracingChannel
* is not compatible with Electron's Node.js version.
*
* This provides structured JSON logging to stdout with the same interface.
*/
export class PinoLogAdapter implements ILogger {
private readonly config: LoggingEnvironmentConfig;
private readonly baseContext: LogContext;
private readonly levelPriority: number;
constructor(config?: LoggingEnvironmentConfig, baseContext?: LogContext) {
this.config = config || loadLoggingConfig();
this.baseContext = {
app: 'gridpilot-companion',
version: process.env.npm_package_version || '0.0.0',
processType: process.type || 'main',
...baseContext,
};
this.levelPriority = LOG_LEVEL_PRIORITY[this.config.level];
}
private shouldLog(level: LogLevel): boolean {
return LOG_LEVEL_PRIORITY[level] >= this.levelPriority;
}
private formatLog(level: LogLevel, message: string, context?: LogContext, error?: Error): string {
const entry: Record<string, unknown> = {
level,
time: new Date().toISOString(),
...this.baseContext,
...context,
msg: message,
};
if (error) {
entry.err = {
message: error.message,
name: error.name,
stack: error.stack,
};
}
return JSON.stringify(entry);
}
private log(level: LogLevel, message: string, context?: LogContext, error?: Error): void {
if (!this.shouldLog(level)) {
return;
}
const output = this.formatLog(level, message, context, error);
if (this.config.prettyPrint) {
const timestamp = new Date().toLocaleString();
const levelColors: Record<LogLevel, string> = {
debug: '\x1b[36m', // cyan
info: '\x1b[32m', // green
warn: '\x1b[33m', // yellow
error: '\x1b[31m', // red
fatal: '\x1b[35m', // magenta
};
const reset = '\x1b[0m';
const color = levelColors[level];
const contextStr = context ? ` ${JSON.stringify(context)}` : '';
const errorStr = error ? `\n ${error.stack || error.message}` : '';
console.log(`${color}[${timestamp}] ${level.toUpperCase()}${reset}: ${message}${contextStr}${errorStr}`);
} else {
console.log(output);
}
}
debug(message: string, context?: LogContext): void {
this.log('debug', message, context);
}
info(message: string, context?: LogContext): void {
this.log('info', message, context);
}
warn(message: string, context?: LogContext): void {
this.log('warn', message, context);
}
error(message: string, error?: Error, context?: LogContext): void {
this.log('error', message, context, error);
}
fatal(message: string, error?: Error, context?: LogContext): void {
this.log('fatal', message, context, error);
}
child(context: LogContext): ILogger {
return new PinoLogAdapter(this.config, { ...this.baseContext, ...context });
}
async flush(): Promise<void> {
// Console output is synchronous, nothing to flush
}
}

View File

@@ -0,0 +1,2 @@
export { PinoLogAdapter } from './PinoLogAdapter';
export { NoOpLogAdapter } from './NoOpLogAdapter';

View File

@@ -0,0 +1,211 @@
/**
* 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 NODE_ENV.
*
* Mapping:
* - NODE_ENV=production → real browser automation → iRacing Window → Image Templates
* - NODE_ENV=development → real browser automation → iRacing Window → Image Templates
* - NODE_ENV=test → MockBrowserAutomation → N/A → N/A
*/
export type AutomationMode = 'production' | 'development' | 'test';
/**
* @deprecated Use AutomationMode instead. Will be removed in future version.
*/
export type LegacyAutomationMode = 'dev' | 'production' | 'mock';
/**
* Retry configuration for element finding operations.
*/
export interface RetryConfig {
/** Maximum number of retry attempts (default: 3) */
maxRetries: number;
/** Initial delay in milliseconds before first retry (default: 500) */
baseDelayMs: number;
/** Maximum delay in milliseconds between retries (default: 5000) */
maxDelayMs: number;
/** Multiplier for exponential backoff (default: 2.0) */
backoffMultiplier: number;
}
/**
* Timing configuration for automation operations.
*/
export interface TimingConfig {
/** Wait time for page to load after opening browser (default: 5000) */
pageLoadWaitMs: number;
/** Delay between sequential actions (default: 200) */
interActionDelayMs: number;
/** Delay after clicking an element (default: 300) */
postClickDelayMs: number;
/** Delay before starting step execution (default: 100) */
preStepDelayMs: number;
}
/**
* Default retry configuration values.
*/
export const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxRetries: 3,
baseDelayMs: 500,
maxDelayMs: 5000,
backoffMultiplier: 2.0,
};
/**
* Default timing configuration values.
*/
export const DEFAULT_TIMING_CONFIG: TimingConfig = {
pageLoadWaitMs: 5000,
interActionDelayMs: 200,
postClickDelayMs: 300,
preStepDelayMs: 100,
};
export interface AutomationEnvironmentConfig {
mode: AutomationMode;
/** Production/development configuration for native automation */
nutJs?: {
mouseSpeed?: number;
keyboardDelay?: number;
windowTitle?: string;
templatePath?: string;
confidence?: number;
/** Retry configuration for element finding */
retry?: Partial<RetryConfig>;
/** Timing configuration for waits */
timing?: Partial<TimingConfig>;
};
/** Default timeout for automation operations in milliseconds */
defaultTimeout?: number;
/** Number of retry attempts for failed operations */
retryAttempts?: number;
/** Whether to capture screenshots on error */
screenshotOnError?: boolean;
}
/**
* Get the automation mode based on NODE_ENV.
*
* Mapping:
* - NODE_ENV=production → 'production'
* - 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.
*
* @returns AutomationMode derived from NODE_ENV
*/
export function getAutomationMode(): AutomationMode {
const legacyMode = process.env.AUTOMATION_MODE;
if (legacyMode && isValidLegacyAutomationMode(legacyMode)) {
console.warn(
`[DEPRECATED] AUTOMATION_MODE environment variable is deprecated. ` +
`Use NODE_ENV instead. Mapping: dev→test, mock→test, production→production`
);
return mapLegacyMode(legacyMode);
}
const nodeEnv = process.env.NODE_ENV;
// Map NODE_ENV to AutomationMode
if (nodeEnv === 'production') return 'production';
if (nodeEnv === 'development') return 'development';
return 'test';
}
/**
* Load automation configuration from environment variables.
*
* Environment variables:
* - NODE_ENV: 'production' | 'test' (default: 'test')
* - AUTOMATION_MODE: (deprecated) 'dev' | 'production' | 'mock'
* - IRACING_WINDOW_TITLE: Window title for native automation (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 mode = getAutomationMode();
return {
mode,
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/iracing',
confidence: parseFloatSafe(process.env.OCR_CONFIDENCE, 0.9),
retry: {
maxRetries: parseIntSafe(process.env.AUTOMATION_MAX_RETRIES, DEFAULT_RETRY_CONFIG.maxRetries),
baseDelayMs: parseIntSafe(process.env.AUTOMATION_BASE_DELAY_MS, DEFAULT_RETRY_CONFIG.baseDelayMs),
maxDelayMs: parseIntSafe(process.env.AUTOMATION_MAX_DELAY_MS, DEFAULT_RETRY_CONFIG.maxDelayMs),
backoffMultiplier: parseFloatSafe(process.env.AUTOMATION_BACKOFF_MULTIPLIER, DEFAULT_RETRY_CONFIG.backoffMultiplier),
},
timing: {
pageLoadWaitMs: parseIntSafe(process.env.AUTOMATION_PAGE_LOAD_WAIT_MS, DEFAULT_TIMING_CONFIG.pageLoadWaitMs),
interActionDelayMs: parseIntSafe(process.env.AUTOMATION_INTER_ACTION_DELAY_MS, DEFAULT_TIMING_CONFIG.interActionDelayMs),
postClickDelayMs: parseIntSafe(process.env.AUTOMATION_POST_CLICK_DELAY_MS, DEFAULT_TIMING_CONFIG.postClickDelayMs),
preStepDelayMs: parseIntSafe(process.env.AUTOMATION_PRE_STEP_DELAY_MS, DEFAULT_TIMING_CONFIG.preStepDelayMs),
},
},
defaultTimeout: parseIntSafe(process.env.AUTOMATION_TIMEOUT, 30000),
retryAttempts: parseIntSafe(process.env.RETRY_ATTEMPTS, 3),
screenshotOnError: process.env.SCREENSHOT_ON_ERROR !== 'false',
};
}
/**
* Type guard to validate automation mode string.
*/
function isValidAutomationMode(value: string | undefined): value is AutomationMode {
return value === 'production' || value === 'development' || value === 'test';
}
/**
* Type guard to validate legacy automation mode string.
*/
function isValidLegacyAutomationMode(value: string | undefined): value is LegacyAutomationMode {
return value === 'dev' || value === 'production' || value === 'mock';
}
/**
* Map legacy automation mode to new mode.
*/
function mapLegacyMode(legacy: LegacyAutomationMode): AutomationMode {
switch (legacy) {
case 'dev': return 'test';
case 'mock': return 'test';
case 'production': return 'production';
}
}
/**
* 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,59 @@
/**
* Browser mode configuration module for headed/headless browser toggle.
*
* Determines browser mode based on NODE_ENV:
* - development: default headed, but configurable via runtime setter
* - production: always headless
* - test: always headless
* - default: headless (for safety)
*/
export type BrowserMode = 'headed' | 'headless';
export interface BrowserModeConfig {
mode: BrowserMode;
source: 'GUI' | 'NODE_ENV';
}
/**
* Loader for browser mode configuration.
* Determines whether browser should run in headed or headless mode based on NODE_ENV.
* In development mode, provides runtime control via setter method.
*/
export class BrowserModeConfigLoader {
private developmentMode: BrowserMode = 'headless'; // Default to headless in development
/**
* Load browser mode configuration based on NODE_ENV.
* - NODE_ENV=development: returns current developmentMode (default: headed)
* - NODE_ENV=production: always headless
* - NODE_ENV=test: always headless
* - default: headless (for safety)
*/
load(): BrowserModeConfig {
const nodeEnv = process.env.NODE_ENV || 'production';
if (nodeEnv === 'development') {
return { mode: this.developmentMode, source: 'GUI' };
}
return { mode: 'headless', source: 'NODE_ENV' };
}
/**
* Set browser mode for development environment.
* Only affects behavior when NODE_ENV=development.
* @param mode - The browser mode to use in development
*/
setDevelopmentMode(mode: BrowserMode): void {
this.developmentMode = mode;
}
/**
* Get current development browser mode setting.
* @returns The current browser mode for development
*/
getDevelopmentMode(): BrowserMode {
return this.developmentMode;
}
}

View File

@@ -0,0 +1,82 @@
import type { LogLevel } from '../../application/ports/ILogger';
export type LogEnvironment = 'development' | 'production' | 'test';
export interface LoggingEnvironmentConfig {
level: LogLevel;
prettyPrint: boolean;
fileOutput: boolean;
filePath?: string;
maxFiles?: number;
maxFileSize?: string;
}
/**
* Load logging configuration from environment variables.
*
* Environment variables:
* - NODE_ENV: 'development' | 'production' | 'test' (default: 'development')
* - LOG_LEVEL: Override log level (optional)
* - LOG_FILE_PATH: Path for log files in production (default: './logs/gridpilot')
* - LOG_MAX_FILES: Max rotated files to keep (default: 7)
* - LOG_MAX_SIZE: Max file size before rotation (default: '10m')
*
* @returns LoggingEnvironmentConfig with parsed environment values
*/
export function loadLoggingConfig(): LoggingEnvironmentConfig {
const envValue = process.env.NODE_ENV;
const environment = isValidLogEnvironment(envValue) ? envValue : 'development';
const defaults = getDefaultsForEnvironment(environment);
const levelOverride = process.env.LOG_LEVEL;
const level = isValidLogLevel(levelOverride) ? levelOverride : defaults.level;
return {
level,
prettyPrint: defaults.prettyPrint,
fileOutput: defaults.fileOutput,
filePath: process.env.LOG_FILE_PATH || './logs/gridpilot',
maxFiles: parseIntSafe(process.env.LOG_MAX_FILES, 7),
maxFileSize: process.env.LOG_MAX_SIZE || '10m',
};
}
function getDefaultsForEnvironment(env: LogEnvironment): LoggingEnvironmentConfig {
switch (env) {
case 'development':
return {
level: 'debug',
prettyPrint: true,
fileOutput: false,
};
case 'production':
return {
level: 'info',
prettyPrint: false,
fileOutput: true,
};
case 'test':
return {
level: 'warn',
prettyPrint: false,
fileOutput: false,
};
}
}
function isValidLogEnvironment(value: string | undefined): value is LogEnvironment {
return value === 'development' || value === 'production' || value === 'test';
}
function isValidLogLevel(value: string | undefined): value is LogLevel {
return value === 'debug' || value === 'info' || value === 'warn' || value === 'error' || value === 'fatal';
}
function parseIntSafe(value: string | undefined, defaultValue: number): number {
if (value === undefined || value === '') {
return defaultValue;
}
const parsed = parseInt(value, 10);
return isNaN(parsed) ? defaultValue : parsed;
}

View File

@@ -0,0 +1,8 @@
/**
* Infrastructure configuration barrel export.
* Exports all configuration modules for easy imports.
*/
export * from './AutomationConfig';
export * from './LoggingConfig';
export * from './BrowserModeConfig';

View File

@@ -0,0 +1,36 @@
import { AutomationSession } from '@gridpilot/automation/domain/entities/AutomationSession';
import { SessionStateValue } from '@gridpilot/automation/domain/value-objects/SessionState';
import { ISessionRepository } from '../../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,13 @@
{
"name": "@gridpilot/automation",
"version": "0.1.0",
"main": "./index.ts",
"types": "./index.ts",
"type": "module",
"exports": {
"./domain/*": "./domain/*",
"./application/*": "./application/*",
"./infrastructure/*": "./infrastructure/*"
},
"dependencies": {}
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",
"declaration": true,
"declarationMap": false
},
"include": ["**/*.ts"]
}