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,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);
}
}