This commit is contained in:
2025-11-26 17:03:29 +01:00
parent ff3528e5ef
commit fef75008d8
147 changed files with 112370 additions and 5162 deletions

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,102 @@
import { Result } from '../../../shared/result/Result';
import { CheckoutPrice } from '../../../domain/value-objects/CheckoutPrice';
import { CheckoutState } from '../../../domain/value-objects/CheckoutState';
import { CheckoutInfo } from '../../../application/ports/ICheckoutService';
interface Page {
locator(selector: string): Locator;
}
interface Locator {
getAttribute(name: string): Promise<string | null>;
innerHTML(): Promise<string>;
textContent(): Promise<string | null>;
}
export class CheckoutPriceExtractor {
private readonly selector = '.wizard-footer a.btn:has(span.label-pill)';
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('span.label-pill');
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').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

@@ -11,7 +11,7 @@ export interface IFixtureServer {
/**
* Step number to fixture file mapping.
* Steps 2-18 map to the corresponding HTML fixture files.
* Steps 2-17 map to the corresponding HTML fixture files.
*/
const STEP_TO_FIXTURE: Record<number, string> = {
2: 'step-02-hosted-racing.html',
@@ -19,18 +19,17 @@ const STEP_TO_FIXTURE: Record<number, string> = {
4: 'step-04-race-information.html',
5: 'step-05-server-details.html',
6: 'step-06-set-admins.html',
7: 'step-07-add-admin.html',
8: 'step-08-time-limits.html',
9: 'step-09-set-cars.html',
10: 'step-10-add-car.html',
11: 'step-11-set-car-classes.html',
12: 'step-12-set-track.html',
13: 'step-13-add-track.html',
14: 'step-14-track-options.html',
15: 'step-15-time-of-day.html',
16: 'step-16-weather.html',
17: 'step-17-race-options.html',
18: 'step-18-track-conditions.html',
7: 'step-07-time-limits.html', // Time Limits wizard step
8: 'step-08-set-cars.html', // Set Cars wizard step
9: 'step-09-add-car-modal.html', // Add Car modal
10: 'step-10-set-car-classes.html', // Set Car Classes
11: 'step-11-set-track.html', // Set Track wizard step (CORRECTED)
12: 'step-12-add-track-modal.html', // Add Track modal
13: 'step-13-track-options.html',
14: 'step-14-time-of-day.html',
15: 'step-15-weather.html',
16: 'step-16-race-options.html',
17: 'step-17-track-conditions.html',
};
export class FixtureServer implements IFixtureServer {

View File

@@ -111,26 +111,28 @@ export const IRACING_SELECTORS = {
// Step 8/9: Cars
carSearch: '.wizard-sidebar input[placeholder*="Search"], #set-cars input[placeholder*="Search"], .modal input[placeholder*="Search"]',
carList: '#set-cars [data-list="cars"]',
// Add Car button - triggers the Add Car modal
addCarButton: '#set-cars a.btn:has(.icon-plus), #set-cars button:has-text("Add"), #set-cars a.btn:has-text("Add")',
// Add Car modal - appears after clicking Add Car button
addCarModal: '#add-car-modal, .modal:has(input[placeholder*="Search"]):has-text("Car")',
// Select button inside Add Car modal table row - clicking this adds the car immediately (no confirm step)
// Add Car button - triggers car selection interface in wizard sidebar
// CORRECTED: Added fallback selectors since .icon-plus cannot be verified in minified HTML
addCarButton: '#set-cars a.btn:has(.icon-plus), #set-cars .card-header a.btn, #set-cars button:has-text("Add"), #set-cars a.btn:has-text("Add")',
// Car selection interface - CORRECTED: No separate modal, uses wizard sidebar within main modal
addCarModal: '#create-race-modal .wizard-sidebar, #set-cars .wizard-sidebar, .wizard-sidebar:has(input[placeholder*="Search"])',
// Select button inside car table row - clicking this adds the car immediately (no confirm step)
// The "Select" button is an anchor styled as: a.btn.btn-block.btn-primary.btn-xs
carSelectButton: '.modal table .btn-primary:has-text("Select"), .modal .btn-primary.btn-xs:has-text("Select"), .modal tbody .btn-primary',
carSelectButton: '.wizard-sidebar table .btn-primary.btn-xs:has-text("Select"), #set-cars table .btn-primary.btn-xs:has-text("Select"), .modal table .btn-primary:has-text("Select")',
// Step 10/11/12: Track
trackSearch: '.wizard-sidebar input[placeholder*="Search"], #set-track input[placeholder*="Search"], .modal input[placeholder*="Search"]',
trackList: '#set-track [data-list="tracks"]',
// Add Track button - triggers the Add Track modal
addTrackButton: '#set-track a.btn:has(.icon-plus), #set-track button:has-text("Add"), #set-track a.btn:has-text("Add"), #set-track button:has-text("Select"), #set-track a.btn:has-text("Select")',
// Add Track modal - appears after clicking Add Track button
addTrackModal: '#add-track-modal, .modal:has(input[placeholder*="Search"]):has-text("Track")',
// Select button inside Add Track modal table row - clicking this selects the track immediately (no confirm step)
// Add Track button - triggers track selection interface in wizard sidebar
// CORRECTED: Added fallback selectors since .icon-plus cannot be verified in minified HTML
addTrackButton: '#set-track a.btn:has(.icon-plus), #set-track .card-header a.btn, #set-track button:has-text("Add"), #set-track a.btn:has-text("Add")',
// Track selection interface - CORRECTED: No separate modal, uses wizard sidebar within main modal
addTrackModal: '#create-race-modal .wizard-sidebar, #set-track .wizard-sidebar, .wizard-sidebar:has(input[placeholder*="Search"])',
// Select button inside track table row - clicking this selects the track immediately (no confirm step)
// Prefer direct buttons (not dropdown toggles) for single-config tracks
trackSelectButton: '.modal table a.btn.btn-primary.btn-xs:not(.dropdown-toggle)',
trackSelectButton: '.wizard-sidebar table a.btn.btn-primary.btn-xs:not(.dropdown-toggle), #set-track table a.btn.btn-primary.btn-xs:not(.dropdown-toggle)',
// Dropdown toggle for multi-config tracks - opens a menu of track configurations
trackSelectDropdown: '.modal table a.btn.btn-primary.btn-xs.dropdown-toggle',
trackSelectDropdown: '.wizard-sidebar table a.btn.btn-primary.btn-xs.dropdown-toggle, #set-track table a.btn.btn-primary.btn-xs.dropdown-toggle',
// First item in the dropdown menu for selecting track configuration
trackSelectDropdownItem: '.dropdown-menu.show .dropdown-item:first-child, .dropdown-menu-lg .dropdown-item:first-child',

View File

@@ -1,12 +1,15 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { AuthenticationState } from '../../../domain/value-objects/AuthenticationState';
import { CookieConfiguration } from '../../../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;
}
@@ -33,6 +36,7 @@ const IRACING_DOMAINS = [
'iracing.com',
'.iracing.com',
'members.iracing.com',
'members-ng.iracing.com',
];
const EXPIRY_BUFFER_SECONDS = 300;
@@ -63,13 +67,23 @@ export class SessionCookieStore {
async read(): Promise<StorageState | null> {
try {
const content = await fs.readFile(this.storagePath, 'utf-8');
return JSON.parse(content) as StorageState;
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');
}
@@ -81,6 +95,65 @@ export class SessionCookieStore {
}
}
/**
* 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.
*
@@ -192,4 +265,114 @@ export class SessionCookieStore {
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('No cookies found in session store');
}
const result = this.validateCookiesForUrl(this.cachedState.cookies, targetUrl, true);
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Result.err(`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 {
// Validate each cookie's domain/path
const validatedCookies: Cookie[] = [];
let firstValidationError: string | null = null;
for (const cookie of cookies) {
try {
new CookieConfiguration(cookie, targetUrl);
validatedCookies.push(cookie);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// Capture first validation error to return if all cookies fail
if (!firstValidationError) {
firstValidationError = message;
}
this.logger?.warn('Cookie validation failed', {
name: cookie.name,
error: message,
});
// Skip invalid cookie, continue with others
}
}
if (validatedCookies.length === 0) {
// Return the specific validation error from the first failed cookie
return Result.err(firstValidationError || 'No valid cookies found for target URL');
}
// Check required cookies only if requested (for authentication validation)
if (requireAuthCookies) {
const cookieNames = validatedCookies.map((c) => c.name.toLowerCase());
// Check for irsso_members
const hasIrssoMembers = cookieNames.some((name) =>
name.includes('irsso_members') || name.includes('irsso')
);
// Check for authtoken_members
const hasAuthtokenMembers = cookieNames.some((name) =>
name.includes('authtoken_members') || name.includes('authtoken')
);
if (!hasIrssoMembers) {
return Result.err('Required cookie missing: irsso_members');
}
if (!hasAuthtokenMembers) {
return Result.err('Required cookie missing: authtoken_members');
}
}
return Result.ok(validatedCookies);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Result.err(`Cookie validation failed: ${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,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 '../../../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,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 = 'headed'; // Default to headed 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

@@ -1,6 +1,8 @@
/**
* Configuration module exports for infrastructure layer.
* Infrastructure configuration barrel export.
* Exports all configuration modules for easy imports.
*/
export type { AutomationMode, AutomationEnvironmentConfig } from './AutomationConfig';
export { loadAutomationConfig, getAutomationMode } from './AutomationConfig';
export * from './AutomationConfig';
export * from './LoggingConfig';
export * from './BrowserModeConfig';