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

3
.browser-config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"mode": "headless"
}

5
.gitignore vendored
View File

@@ -10,6 +10,11 @@ dist/
build/
*.tsbuildinfo
debug-screenshots
test-user-data
playwright-report
test-results
# Environment variables
.env
.env.local

View File

@@ -1,295 +0,0 @@
<html><head><style type="text/css">
@keyframes gridpilot-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.85; transform: scale(1.03); }
}
@keyframes gridpilot-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes gridpilot-slide-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes gridpilot-checkered {
0% { background-position: 0 0; }
100% { background-position: 20px 20px; }
}
@keyframes gridpilot-progress {
0% { background-position: 0% 50%; }
100% { background-position: 100% 50%; }
}
#gridpilot-overlay {
position: fixed;
bottom: 20px;
right: 20px;
width: 340px;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
z-index: 2147483647;
animation: gridpilot-slide-in 0.4s ease-out;
pointer-events: auto;
}
#gridpilot-overlay * {
box-sizing: border-box;
}
.gridpilot-card {
background: #12121B;
border-radius: 4px;
border: 1px solid rgba(183, 183, 187, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
overflow: hidden;
}
.gridpilot-header {
background: linear-gradient(90deg, #c8102e 0%, #a00d25 100%);
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
position: relative;
overflow: hidden;
}
.gridpilot-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
linear-gradient(45deg, transparent 48%, rgba(255,255,255,0.05) 49%, rgba(255,255,255,0.05) 51%, transparent 52%),
linear-gradient(-45deg, transparent 48%, rgba(255,255,255,0.05) 49%, rgba(255,255,255,0.05) 51%, transparent 52%);
background-size: 8px 8px;
animation: gridpilot-checkered 1.5s linear infinite;
opacity: 0.5;
}
.gridpilot-logo {
font-size: 22px;
animation: gridpilot-pulse 2s ease-in-out infinite;
position: relative;
z-index: 1;
}
.gridpilot-title {
color: #ffffff;
font-size: 13px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
position: relative;
z-index: 1;
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
flex: 1;
}
.gridpilot-btn {
background: rgba(255, 255, 255, 0.15);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
cursor: pointer;
position: relative;
z-index: 1;
transition: all 0.15s ease;
}
.gridpilot-btn:hover {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.5);
}
.gridpilot-btn:active {
background: rgba(255, 255, 255, 0.35);
transform: scale(0.97);
}
.gridpilot-btn.paused {
background: #4e4e57;
border-color: #ffffff;
color: #ffffff;
animation: gridpilot-pulse 1s ease-in-out infinite;
}
.gridpilot-close-btn {
background: rgba(200, 16, 46, 0.6);
border-color: rgba(200, 16, 46, 0.8);
}
.gridpilot-close-btn:hover {
background: rgba(200, 16, 46, 0.8);
border-color: #c8102e;
}
.gridpilot-close-btn:active {
background: #c8102e;
}
.gridpilot-header-buttons {
display: flex;
gap: 6px;
position: relative;
z-index: 1;
}
.gridpilot-body {
padding: 14px;
background: #1a1a24;
}
.gridpilot-status {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.gridpilot-spinner {
width: 22px;
height: 22px;
border: 2px solid rgba(200, 16, 46, 0.3);
border-top-color: #c8102e;
border-radius: 50%;
animation: gridpilot-spin 0.8s linear infinite;
flex-shrink: 0;
}
.gridpilot-spinner.paused {
animation-play-state: paused;
border-top-color: #777880;
border-color: rgba(119, 120, 128, 0.3);
}
.gridpilot-action-text {
color: rgba(255, 255, 255, 0.92);
font-size: 14px;
font-weight: 500;
line-height: 1.4;
}
.gridpilot-progress-container {
margin-bottom: 12px;
}
.gridpilot-progress-bar {
height: 4px;
background: rgba(78, 78, 87, 0.5);
border-radius: 2px;
overflow: hidden;
}
.gridpilot-progress-fill {
height: 100%;
background: linear-gradient(90deg, #c8102e, #e8304a, #c8102e);
background-size: 200% 100%;
animation: gridpilot-progress 2s linear infinite;
border-radius: 2px;
transition: width 0.4s ease-out;
}
.gridpilot-progress-fill.paused {
animation-play-state: paused;
background: #777880;
}
.gridpilot-step-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 6px;
}
.gridpilot-step-text {
color: rgba(255, 255, 255, 0.6);
font-size: 11px;
}
.gridpilot-step-count {
color: #c8102e;
font-size: 11px;
font-weight: 600;
}
.gridpilot-personality {
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
font-style: italic;
text-align: center;
padding-top: 10px;
border-top: 1px solid rgba(183, 183, 187, 0.15);
}
.gridpilot-footer {
background: #12121B;
padding: 8px 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border-top: 1px solid rgba(183, 183, 187, 0.1);
}
.gridpilot-footer-text {
color: rgba(255, 255, 255, 0.4);
font-size: 10px;
letter-spacing: 0.5px;
}
.gridpilot-footer-dot {
width: 4px;
height: 4px;
background: #c8102e;
border-radius: 50%;
animation: gridpilot-pulse 1.5s ease-in-out infinite;
}
.gridpilot-footer-dot.paused {
background: #777880;
animation: none;
}
</style></head><body><div id="gridpilot-overlay">
<div class="gridpilot-card">
<div class="gridpilot-header">
<span class="gridpilot-logo">🏎️</span>
<span class="gridpilot-title">GridPilot</span>
<div class="gridpilot-header-buttons">
<button class="gridpilot-btn gridpilot-close-btn" id="gridpilot-close-btn" onclick="(function() {
window.__gridpilot_close_requested = true;
})()">Stop</button>
</div>
</div>
<div class="gridpilot-body">
<div class="gridpilot-status">
<div class="gridpilot-spinner"></div>
<span class="gridpilot-action-text" id="gridpilot-action">Processing step 0...</span>
</div>
<div class="gridpilot-progress-container">
<div class="gridpilot-progress-bar">
<div class="gridpilot-progress-fill" id="gridpilot-progress" style="width: 0%"></div>
</div>
<div class="gridpilot-step-info">
<span class="gridpilot-step-text" id="gridpilot-step-text">Processing step 0...</span>
<span class="gridpilot-step-count" id="gridpilot-step-count">Step 0 of 17</span>
</div>
</div>
<div class="gridpilot-personality" id="gridpilot-personality">🏁 Getting ready for the green flag...</div>
</div>
<div class="gridpilot-footer">
<div class="gridpilot-footer-dot"></div>
<span class="gridpilot-footer-text">Automating your session setup</span>
</div>
</div>
</div></body></html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -8,7 +8,8 @@ import { StartAutomationSessionUseCase } from '@/packages/application/use-cases/
import { CheckAuthenticationUseCase } from '@/packages/application/use-cases/CheckAuthenticationUseCase';
import { InitiateLoginUseCase } from '@/packages/application/use-cases/InitiateLoginUseCase';
import { ClearSessionUseCase } from '@/packages/application/use-cases/ClearSessionUseCase';
import { loadAutomationConfig, getAutomationMode, AutomationMode } from '@/packages/infrastructure/config';
import { ConfirmCheckoutUseCase } from '@/packages/application/use-cases/ConfirmCheckoutUseCase';
import { loadAutomationConfig, getAutomationMode, AutomationMode, BrowserModeConfigLoader } from '@/packages/infrastructure/config';
import { PinoLogAdapter } from '@/packages/infrastructure/adapters/logging/PinoLogAdapter';
import { NoOpLogAdapter } from '@/packages/infrastructure/adapters/logging/NoOpLogAdapter';
import { loadLoggingConfig } from '@/packages/infrastructure/config/LoggingConfig';
@@ -16,6 +17,7 @@ import type { ISessionRepository } from '@/packages/application/ports/ISessionRe
import type { IScreenAutomation } from '@/packages/application/ports/IScreenAutomation';
import type { IAutomationEngine } from '@/packages/application/ports/IAutomationEngine';
import type { IAuthenticationService } from '@/packages/application/ports/IAuthenticationService';
import type { ICheckoutConfirmationPort } from '@/packages/application/ports/ICheckoutConfirmationPort';
import type { ILogger } from '@/packages/application/ports/ILogger';
export interface BrowserConnectionResult {
@@ -92,7 +94,11 @@ function getAdapterMode(envMode: AutomationMode): AutomationAdapterMode {
* @param logger - Logger instance for the adapter
* @returns PlaywrightAutomationAdapter instance (implements both IScreenAutomation and IAuthenticationService)
*/
function createBrowserAutomationAdapter(mode: AutomationMode, logger: ILogger): PlaywrightAutomationAdapter | MockBrowserAutomationAdapter {
function createBrowserAutomationAdapter(
mode: AutomationMode,
logger: ILogger,
browserModeConfigLoader: BrowserModeConfigLoader
): PlaywrightAutomationAdapter | MockBrowserAutomationAdapter {
const config = loadAutomationConfig();
// Resolve absolute template path for Electron environment
@@ -108,18 +114,28 @@ function createBrowserAutomationAdapter(mode: AutomationMode, logger: ILogger):
});
const adapterMode = getAdapterMode(mode);
logger.info('Creating browser automation adapter', { envMode: mode, adapterMode });
// Get browser mode configuration from provided loader
const browserModeConfig = browserModeConfigLoader.load();
logger.info('Creating browser automation adapter', {
envMode: mode,
adapterMode,
browserMode: browserModeConfig.mode,
browserModeSource: browserModeConfig.source,
});
switch (mode) {
case 'production':
case 'development':
return new PlaywrightAutomationAdapter(
{
headless: mode === 'production',
headless: browserModeConfig.mode === 'headless',
mode: adapterMode,
userDataDir: sessionDataPath,
},
logger.child({ adapter: 'Playwright', mode: adapterMode })
logger.child({ adapter: 'Playwright', mode: adapterMode }),
browserModeConfigLoader
);
case 'test':
@@ -139,7 +155,9 @@ export class DIContainer {
private checkAuthenticationUseCase: CheckAuthenticationUseCase | null = null;
private initiateLoginUseCase: InitiateLoginUseCase | null = null;
private clearSessionUseCase: ClearSessionUseCase | null = null;
private confirmCheckoutUseCase: ConfirmCheckoutUseCase | null = null;
private automationMode: AutomationMode;
private browserModeConfigLoader: BrowserModeConfigLoader;
private constructor() {
// Initialize logger first - it's needed by other components
@@ -153,8 +171,15 @@ export class DIContainer {
const config = loadAutomationConfig();
// Initialize browser mode config loader as singleton
this.browserModeConfigLoader = new BrowserModeConfigLoader();
this.sessionRepository = new InMemorySessionRepository();
this.browserAutomation = createBrowserAutomationAdapter(config.mode, this.logger);
this.browserAutomation = createBrowserAutomationAdapter(
config.mode,
this.logger,
this.browserModeConfigLoader
);
this.automationEngine = new MockAutomationEngineAdapter(
this.browserAutomation,
this.sessionRepository
@@ -241,6 +266,21 @@ export class DIContainer {
return null;
}
public setConfirmCheckoutUseCase(
checkoutConfirmationPort: ICheckoutConfirmationPort
): void {
// Create ConfirmCheckoutUseCase with checkout service from browser automation
// and the provided confirmation port
this.confirmCheckoutUseCase = new ConfirmCheckoutUseCase(
this.browserAutomation as any, // implements ICheckoutService
checkoutConfirmationPort
);
}
public getConfirmCheckoutUseCase(): ConfirmCheckoutUseCase | null {
return this.confirmCheckoutUseCase;
}
/**
* Initialize automation connection based on mode.
* In production/development mode, connects via Playwright browser automation.
@@ -292,6 +332,14 @@ export class DIContainer {
this.logger.info('DIContainer shutdown complete');
}
/**
* Get the browser mode configuration loader.
* Provides access to runtime browser mode control (headed/headless).
*/
public getBrowserModeConfigLoader(): BrowserModeConfigLoader {
return this.browserModeConfigLoader;
}
/**
* Reset the singleton instance (useful for testing with different configurations).
*/

View File

@@ -4,6 +4,7 @@ import { DIContainer } from './di-container';
import type { HostedSessionConfig } from '@/packages/domain/entities/HostedSessionConfig';
import { StepId } from '@/packages/domain/value-objects/StepId';
import { AuthenticationState } from '@/packages/domain/value-objects/AuthenticationState';
import { ElectronCheckoutConfirmationAdapter } from '@/packages/infrastructure/adapters/ipc/ElectronCheckoutConfirmationAdapter';
let progressMonitorInterval: NodeJS.Timeout | null = null;
@@ -14,6 +15,10 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
const automationEngine = container.getAutomationEngine();
const logger = container.getLogger();
// Setup checkout confirmation adapter and wire it into the container
const checkoutConfirmationAdapter = new ElectronCheckoutConfirmationAdapter(mainWindow);
container.setConfirmCheckoutUseCase(checkoutConfirmationAdapter);
// Authentication handlers
ipcMain.handle('auth:check', async () => {
try {
@@ -21,11 +26,10 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
const checkAuthUseCase = container.getCheckAuthenticationUseCase();
if (!checkAuthUseCase) {
logger.warn('Authentication not available in mock mode');
logger.error('Authentication use case not available');
return {
success: true,
state: AuthenticationState.AUTHENTICATED,
message: 'Mock mode - authentication bypassed'
success: false,
error: 'Authentication not available - check system configuration'
};
}
@@ -301,4 +305,36 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
};
}
});
// Browser mode control handlers
ipcMain.handle('browser-mode:get', async () => {
try {
const loader = container.getBrowserModeConfigLoader();
if (process.env.NODE_ENV === 'development') {
return { mode: loader.getDevelopmentMode(), isDevelopment: true };
}
return { mode: 'headless', isDevelopment: false };
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
logger.error('Failed to get browser mode', err);
return { mode: 'headless', isDevelopment: false };
}
});
ipcMain.handle('browser-mode:set', async (_event: IpcMainInvokeEvent, mode: 'headed' | 'headless') => {
try {
if (process.env.NODE_ENV === 'development') {
const loader = container.getBrowserModeConfigLoader();
loader.setDevelopmentMode(mode);
logger.info('Browser mode updated', { mode });
return { success: true, mode };
}
logger.warn('Browser mode change requested but not in development mode');
return { success: false, error: 'Only available in development mode' };
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
logger.error('Failed to set browser mode', err);
return { success: false, error: err.message };
}
});
}

View File

@@ -20,6 +20,17 @@ export interface AuthActionResponse {
error?: string;
}
export interface CheckoutConfirmationRequest {
price: string;
state: 'ready' | 'insufficient_funds';
sessionMetadata: {
sessionName: string;
trackId: string;
carIds: string[];
};
timeoutMs: number;
}
export interface ElectronAPI {
startAutomation: (config: HostedSessionConfig) => Promise<{
success: boolean;
@@ -37,6 +48,12 @@ export interface ElectronAPI {
initiateLogin: () => Promise<AuthActionResponse>;
confirmLogin: () => Promise<AuthActionResponse>;
logout: () => Promise<AuthActionResponse>;
// Browser Mode APIs
getBrowserMode: () => Promise<{ mode: 'headed' | 'headless'; isDevelopment: boolean }>;
setBrowserMode: (mode: 'headed' | 'headless') => Promise<{ success: boolean; mode?: string; error?: string }>;
// Checkout Confirmation APIs
onCheckoutConfirmationRequest: (callback: (request: CheckoutConfirmationRequest) => void) => () => void;
confirmCheckout: (decision: 'confirmed' | 'cancelled' | 'timeout') => void;
}
contextBridge.exposeInMainWorld('electronAPI', {
@@ -56,4 +73,18 @@ contextBridge.exposeInMainWorld('electronAPI', {
initiateLogin: () => ipcRenderer.invoke('auth:login'),
confirmLogin: () => ipcRenderer.invoke('auth:confirmLogin'),
logout: () => ipcRenderer.invoke('auth:logout'),
// Browser Mode APIs
getBrowserMode: () => ipcRenderer.invoke('browser-mode:get'),
setBrowserMode: (mode: 'headed' | 'headless') => ipcRenderer.invoke('browser-mode:set', mode),
// Checkout Confirmation APIs
onCheckoutConfirmationRequest: (callback: (request: CheckoutConfirmationRequest) => void) => {
const listener = (_event: any, request: CheckoutConfirmationRequest) => callback(request);
ipcRenderer.on('checkout:request-confirmation', listener);
return () => {
ipcRenderer.removeListener('checkout:request-confirmation', listener);
};
},
confirmCheckout: (decision: 'confirmed' | 'cancelled' | 'timeout') => {
ipcRenderer.send('checkout:confirm', decision);
},
} as ElectronAPI);

View File

@@ -5,7 +5,7 @@
"main": "dist/main/main.cjs",
"type": "module",
"scripts": {
"dev": "unset ELECTRON_RUN_AS_NODE && electron-vite dev",
"dev": "NODE_ENV=development unset ELECTRON_RUN_AS_NODE && electron-vite dev",
"build": "electron-vite build",
"preview": "unset ELECTRON_RUN_AS_NODE && electron-vite preview",
"start": "unset ELECTRON_RUN_AS_NODE && electron ."

View File

@@ -2,6 +2,9 @@ import React, { useState, useEffect, useCallback } from 'react';
import { SessionCreationForm } from './components/SessionCreationForm';
import { SessionProgressMonitor } from './components/SessionProgressMonitor';
import { LoginPrompt } from './components/LoginPrompt';
import { BrowserModeToggle } from './components/BrowserModeToggle';
import { CheckoutConfirmationDialog } from './components/CheckoutConfirmationDialog';
import { RaceCreationSuccessScreen } from './components/RaceCreationSuccessScreen';
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
interface SessionProgress {
@@ -24,6 +27,26 @@ export function App() {
const [isRunning, setIsRunning] = useState(false);
const [loginStatus, setLoginStatus] = useState<LoginStatus>('idle');
const [checkoutRequest, setCheckoutRequest] = useState<{
price: string;
state: 'ready' | 'insufficient_funds';
sessionMetadata: {
sessionName: string;
trackId: string;
carIds: string[];
};
timeoutMs: number;
} | null>(null);
const [raceCreationResult, setRaceCreationResult] = useState<{
sessionId: string;
sessionName: string;
trackId: string;
carIds: string[];
finalPrice: string;
createdAt: Date;
} | null>(null);
const handleLogin = useCallback(async () => {
if (!window.electronAPI) return;
@@ -91,6 +114,11 @@ export function App() {
}
};
// Subscribe to checkout confirmation requests
const unsubscribeCheckout = window.electronAPI.onCheckoutConfirmationRequest((request) => {
setCheckoutRequest(request);
});
checkAuth();
window.electronAPI.onSessionProgress((newProgress: SessionProgress) => {
@@ -101,6 +129,11 @@ export function App() {
setIsRunning(false);
}
});
// Cleanup subscription on unmount
return () => {
unsubscribeCheckout?.();
};
}, []);
const handleStartAutomation = async (config: HostedSessionConfig) => {
@@ -157,6 +190,16 @@ export function App() {
);
}
// Show checkout confirmation dialog if requested
if (checkoutRequest) {
return <CheckoutConfirmationDialog request={checkoutRequest} />;
}
// Show race creation success screen if completed
if (raceCreationResult) {
return <RaceCreationSuccessScreen result={raceCreationResult} />;
}
if (authState !== 'AUTHENTICATED') {
return (
<LoginPrompt
@@ -178,37 +221,42 @@ export function App() {
<div style={{
flex: 1,
padding: '2rem',
borderRight: '1px solid #333'
borderRight: '1px solid #333',
display: 'flex',
flexDirection: 'column'
}}>
<h1 style={{ marginBottom: '2rem', color: '#fff' }}>
GridPilot Companion
</h1>
<p style={{ marginBottom: '2rem', color: '#aaa' }}>
Hosted Session Automation POC
</p>
<div style={{ flex: 1 }}>
<h1 style={{ marginBottom: '2rem', color: '#fff' }}>
GridPilot Companion
</h1>
<p style={{ marginBottom: '2rem', color: '#aaa' }}>
Hosted Session Automation POC
</p>
<SessionCreationForm
onSubmit={handleStartAutomation}
disabled={isRunning}
/>
{isRunning && (
<button
onClick={handleStopAutomation}
style={{
marginTop: '1rem',
padding: '0.75rem 1.5rem',
backgroundColor: '#dc3545',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '1rem',
fontWeight: 'bold'
}}
>
Stop Automation
</button>
)}
<SessionCreationForm
onSubmit={handleStartAutomation}
disabled={isRunning}
/>
{isRunning && (
<button
onClick={handleStopAutomation}
style={{
marginTop: '1rem',
padding: '0.75rem 1.5rem',
backgroundColor: '#dc3545',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '1rem',
fontWeight: 'bold'
}}
>
Stop Automation
</button>
)}
</div>
<BrowserModeToggle />
</div>
<div style={{
flex: 1,

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from 'react';
export function BrowserModeToggle() {
const [mode, setMode] = useState<'headed' | 'headless'>('headed');
const [isDevelopment, setIsDevelopment] = useState(false);
useEffect(() => {
window.electronAPI.getBrowserMode().then(({ mode, isDevelopment }) => {
setMode(mode);
setIsDevelopment(isDevelopment);
});
}, []);
if (!isDevelopment) return null;
const handleToggle = async () => {
const newMode = mode === 'headed' ? 'headless' : 'headed';
const result = await window.electronAPI.setBrowserMode(newMode);
if (result.success) {
setMode(newMode);
}
};
return (
<div style={{ padding: '10px', borderTop: '1px solid #333' }}>
<label style={{ color: '#aaa', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={mode === 'headless'}
onChange={handleToggle}
style={{ cursor: 'pointer' }}
/>
Headless Browser Mode
</label>
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '4px', marginLeft: '24px' }}>
{mode === 'headless' ? 'Browser runs in background' : 'Browser window visible'}
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
/**
* CheckoutConfirmationDialog component
* Displays checkout information and requests user confirmation before proceeding.
*/
import React, { useEffect, useState } from 'react';
interface CheckoutConfirmationRequest {
price: string;
state: 'ready' | 'insufficient_funds';
sessionMetadata: {
sessionName: string;
trackId: string;
carIds: string[];
};
timeoutMs: number;
}
interface CheckoutConfirmationDialogProps {
request: CheckoutConfirmationRequest;
}
export const CheckoutConfirmationDialog: React.FC<CheckoutConfirmationDialogProps> = ({
request,
}) => {
const [remainingSeconds, setRemainingSeconds] = useState(
Math.floor(request.timeoutMs / 1000)
);
useEffect(() => {
// Countdown timer
const intervalId = setInterval(() => {
setRemainingSeconds((prev) => {
if (prev <= 1) {
clearInterval(intervalId);
window.electronAPI.confirmCheckout('timeout');
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(intervalId);
}, []);
const handleConfirm = () => {
window.electronAPI.confirmCheckout('confirmed');
};
const handleCancel = () => {
window.electronAPI.confirmCheckout('cancelled');
};
return (
<div className="checkout-confirmation-dialog">
<div className="dialog-overlay">
<div className="dialog-content">
<h2>Confirm Checkout</h2>
<div className="checkout-details">
<div className="price-section">
<span className="price-label">Price:</span>
<span className="price-value">{request.price}</span>
</div>
{request.state === 'insufficient_funds' && (
<div className="warning-section">
<span className="warning-text"> Insufficient funds</span>
</div>
)}
<div className="session-info">
<div className="info-row">
<span className="info-label">Session:</span>
<span className="info-value">{request.sessionMetadata.sessionName}</span>
</div>
<div className="info-row">
<span className="info-label">Track:</span>
<span className="info-value">{request.sessionMetadata.trackId}</span>
</div>
<div className="info-row">
<span className="info-label">Cars:</span>
<span className="info-value">
{request.sessionMetadata.carIds.join(', ')}
</span>
</div>
</div>
<div className="countdown-section">
<span className="countdown-text">
Time remaining: {remainingSeconds}s
</span>
</div>
</div>
<div className="dialog-actions">
<button
type="button"
className="btn btn-cancel"
onClick={handleCancel}
>
Cancel
</button>
<button
type="button"
className="btn btn-confirm"
onClick={handleConfirm}
>
Confirm
</button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,78 @@
/**
* RaceCreationSuccessScreen component
* Displays the successful race creation result with session details.
*/
import React from 'react';
interface RaceCreationResult {
sessionId: string;
sessionName: string;
trackId: string;
carIds: string[];
finalPrice: string;
createdAt: Date;
}
interface RaceCreationSuccessScreenProps {
result: RaceCreationResult;
}
export const RaceCreationSuccessScreen: React.FC<RaceCreationSuccessScreenProps> = ({
result,
}) => {
return (
<div className="race-creation-success-screen">
<div className="success-container">
<div className="success-header">
<h2> Race Created Successfully!</h2>
</div>
<div className="success-details">
<div className="detail-section">
<h3>Session Information</h3>
<div className="detail-row">
<span className="detail-label">Session Name:</span>
<span className="detail-value">{result.sessionName}</span>
</div>
<div className="detail-row">
<span className="detail-label">Session ID:</span>
<span className="detail-value">{result.sessionId}</span>
</div>
</div>
<div className="detail-section">
<h3>Track & Cars</h3>
<div className="detail-row">
<span className="detail-label">Track:</span>
<span className="detail-value">{result.trackId}</span>
</div>
<div className="detail-row">
<span className="detail-label">Cars:</span>
<span className="detail-value">{result.carIds.join(', ')}</span>
</div>
</div>
<div className="detail-section">
<h3>Financial</h3>
<div className="detail-row">
<span className="detail-label">Final Price:</span>
<span className="detail-value price">{result.finalPrice}</span>
</div>
</div>
<div className="detail-section">
<h3>Created</h3>
<div className="detail-row">
<span className="detail-label">Timestamp:</span>
<span className="detail-value">
{result.createdAt.toISOString().split('T')[0]} at{' '}
{result.createdAt.toLocaleTimeString()}
</span>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -32,8 +32,7 @@ const STEP_NAMES: { [key: number]: string } = {
14: 'Set Time of Day',
15: 'Configure Weather',
16: 'Set Race Options',
17: 'Configure Team Driving',
18: 'Set Track Conditions'
17: 'Set Track Conditions'
};
export function SessionProgressMonitor({ sessionId, progress, isRunning }: SessionProgressMonitorProps) {
@@ -142,7 +141,7 @@ export function SessionProgressMonitor({ sessionId, progress, isRunning }: Sessi
)}
<div style={{ marginBottom: '1rem', color: '#aaa', fontSize: '14px' }}>
Progress: {progress.completedSteps.length} / 18 steps
Progress: {progress.completedSteps.length} / 17 steps
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>

View File

@@ -1,52 +0,0 @@
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Server Details</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="4">
<header class="header">
<div class="step-indicator" data-indicator="server-details">
<span>Step</span>
<span class="current">4</span>
<span>of 18</span>
<span></span>
<span>Server Details</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Server Details</h1>
<form id="server-details-form">
<div class="form-group">
<label class="form-label" for="region">Server Region</label>
<select id="region" class="form-select" data-dropdown="region">
<option value="us-east">US East</option>
<option value="us-west">US West</option>
<option value="eu-central">EU Central</option>
<option value="eu-west">EU West</option>
<option value="asia">Asia</option>
<option value="oceania">Oceania</option>
</select>
</div>
<div class="toggle-group">
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
<label class="toggle-label" for="startNow">Start session immediately</label>
</div>
</form>
</main>
<footer class="footer">
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
Back
</button>
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
Next
</button>
</footer>
</body></html>

View File

@@ -1,52 +0,0 @@
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Server Details</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="4">
<header class="header">
<div class="step-indicator" data-indicator="server-details">
<span>Step</span>
<span class="current">4</span>
<span>of 18</span>
<span></span>
<span>Server Details</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Server Details</h1>
<form id="server-details-form">
<div class="form-group">
<label class="form-label" for="region">Server Region</label>
<select id="region" class="form-select" data-dropdown="region">
<option value="us-east">US East</option>
<option value="us-west">US West</option>
<option value="eu-central">EU Central</option>
<option value="eu-west">EU West</option>
<option value="asia">Asia</option>
<option value="oceania">Oceania</option>
</select>
</div>
<div class="toggle-group">
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
<label class="toggle-label" for="startNow">Start session immediately</label>
</div>
</form>
</main>
<footer class="footer">
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
Back
</button>
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
Next
</button>
</footer>
</body></html>

View File

@@ -1,52 +0,0 @@
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Server Details</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="4">
<header class="header">
<div class="step-indicator" data-indicator="server-details">
<span>Step</span>
<span class="current">4</span>
<span>of 18</span>
<span></span>
<span>Server Details</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Server Details</h1>
<form id="server-details-form">
<div class="form-group">
<label class="form-label" for="region">Server Region</label>
<select id="region" class="form-select" data-dropdown="region">
<option value="us-east">US East</option>
<option value="us-west">US West</option>
<option value="eu-central">EU Central</option>
<option value="eu-west">EU West</option>
<option value="asia">Asia</option>
<option value="oceania">Oceania</option>
</select>
</div>
<div class="toggle-group">
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
<label class="toggle-label" for="startNow">Start session immediately</label>
</div>
</form>
</main>
<footer class="footer">
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
Back
</button>
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
Next
</button>
</footer>
</body></html>

View File

@@ -1,52 +0,0 @@
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Server Details</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="4">
<header class="header">
<div class="step-indicator" data-indicator="server-details">
<span>Step</span>
<span class="current">4</span>
<span>of 18</span>
<span></span>
<span>Server Details</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Server Details</h1>
<form id="server-details-form">
<div class="form-group">
<label class="form-label" for="region">Server Region</label>
<select id="region" class="form-select" data-dropdown="region">
<option value="us-east">US East</option>
<option value="us-west">US West</option>
<option value="eu-central">EU Central</option>
<option value="eu-west">EU West</option>
<option value="asia">Asia</option>
<option value="oceania">Oceania</option>
</select>
</div>
<div class="toggle-group">
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
<label class="toggle-label" for="startNow">Start session immediately</label>
</div>
</form>
</main>
<footer class="footer">
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
Back
</button>
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
Next
</button>
</footer>
</body></html>

View File

@@ -1,52 +0,0 @@
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Server Details</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="4">
<header class="header">
<div class="step-indicator" data-indicator="server-details">
<span>Step</span>
<span class="current">4</span>
<span>of 18</span>
<span></span>
<span>Server Details</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Server Details</h1>
<form id="server-details-form">
<div class="form-group">
<label class="form-label" for="region">Server Region</label>
<select id="region" class="form-select" data-dropdown="region">
<option value="us-east">US East</option>
<option value="us-west">US West</option>
<option value="eu-central">EU Central</option>
<option value="eu-west">EU West</option>
<option value="asia">Asia</option>
<option value="oceania">Oceania</option>
</select>
</div>
<div class="toggle-group">
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
<label class="toggle-label" for="startNow">Start session immediately</label>
</div>
</form>
</main>
<footer class="footer">
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
Back
</button>
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
Next
</button>
</footer>
</body></html>

View File

@@ -1,52 +0,0 @@
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Server Details</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="4">
<header class="header">
<div class="step-indicator" data-indicator="server-details">
<span>Step</span>
<span class="current">4</span>
<span>of 18</span>
<span></span>
<span>Server Details</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Server Details</h1>
<form id="server-details-form">
<div class="form-group">
<label class="form-label" for="region">Server Region</label>
<select id="region" class="form-select" data-dropdown="region">
<option value="us-east">US East</option>
<option value="us-west">US West</option>
<option value="eu-central">EU Central</option>
<option value="eu-west">EU West</option>
<option value="asia">Asia</option>
<option value="oceania">Oceania</option>
</select>
</div>
<div class="toggle-group">
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
<label class="toggle-label" for="startNow">Start session immediately</label>
</div>
</form>
</main>
<footer class="footer">
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
Back
</button>
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
Next
</button>
</footer>
</body></html>

View File

@@ -1,52 +0,0 @@
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Server Details</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="4">
<header class="header">
<div class="step-indicator" data-indicator="server-details">
<span>Step</span>
<span class="current">4</span>
<span>of 18</span>
<span></span>
<span>Server Details</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Server Details</h1>
<form id="server-details-form">
<div class="form-group">
<label class="form-label" for="region">Server Region</label>
<select id="region" class="form-select" data-dropdown="region">
<option value="us-east">US East</option>
<option value="us-west">US West</option>
<option value="eu-central">EU Central</option>
<option value="eu-west">EU West</option>
<option value="asia">Asia</option>
<option value="oceania">Oceania</option>
</select>
</div>
<div class="toggle-group">
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
<label class="toggle-label" for="startNow">Start session immediately</label>
</div>
</form>
</main>
<footer class="footer">
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
Back
</button>
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
Next
</button>
</footer>
</body></html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1,52 +0,0 @@
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Server Details</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="4">
<header class="header">
<div class="step-indicator" data-indicator="server-details">
<span>Step</span>
<span class="current">4</span>
<span>of 18</span>
<span></span>
<span>Server Details</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Server Details</h1>
<form id="server-details-form">
<div class="form-group">
<label class="form-label" for="region">Server Region</label>
<select id="region" class="form-select" data-dropdown="region">
<option value="us-east">US East</option>
<option value="us-west">US West</option>
<option value="eu-central">EU Central</option>
<option value="eu-west">EU West</option>
<option value="asia">Asia</option>
<option value="oceania">Oceania</option>
</select>
</div>
<div class="toggle-group">
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
<label class="toggle-label" for="startNow">Start session immediately</label>
</div>
</form>
</main>
<footer class="footer">
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
Back
</button>
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
Next
</button>
</footer>
</body></html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1,52 +0,0 @@
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Server Details</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="4">
<header class="header">
<div class="step-indicator" data-indicator="server-details">
<span>Step</span>
<span class="current">4</span>
<span>of 18</span>
<span></span>
<span>Server Details</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Server Details</h1>
<form id="server-details-form">
<div class="form-group">
<label class="form-label" for="region">Server Region</label>
<select id="region" class="form-select" data-dropdown="region">
<option value="us-east">US East</option>
<option value="us-west">US West</option>
<option value="eu-central">EU Central</option>
<option value="eu-west">EU West</option>
<option value="asia">Asia</option>
<option value="oceania">Oceania</option>
</select>
</div>
<div class="toggle-group">
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
<label class="toggle-label" for="startNow">Start session immediately</label>
</div>
</form>
</main>
<footer class="footer">
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
Back
</button>
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
Next
</button>
</footer>
</body></html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1,52 +0,0 @@
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Server Details</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="4">
<header class="header">
<div class="step-indicator" data-indicator="server-details">
<span>Step</span>
<span class="current">4</span>
<span>of 18</span>
<span></span>
<span>Server Details</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Server Details</h1>
<form id="server-details-form">
<div class="form-group">
<label class="form-label" for="region">Server Region</label>
<select id="region" class="form-select" data-dropdown="region">
<option value="us-east">US East</option>
<option value="us-west">US West</option>
<option value="eu-central">EU Central</option>
<option value="eu-west">EU West</option>
<option value="asia">Asia</option>
<option value="oceania">Oceania</option>
</select>
</div>
<div class="toggle-group">
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
<label class="toggle-label" for="startNow">Start session immediately</label>
</div>
</form>
</main>
<footer class="footer">
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
Back
</button>
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
Next
</button>
</footer>
</body></html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1,52 +0,0 @@
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Server Details</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="4">
<header class="header">
<div class="step-indicator" data-indicator="server-details">
<span>Step</span>
<span class="current">4</span>
<span>of 18</span>
<span></span>
<span>Server Details</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Server Details</h1>
<form id="server-details-form">
<div class="form-group">
<label class="form-label" for="region">Server Region</label>
<select id="region" class="form-select" data-dropdown="region">
<option value="us-east">US East</option>
<option value="us-west">US West</option>
<option value="eu-central">EU Central</option>
<option value="eu-west">EU West</option>
<option value="asia">Asia</option>
<option value="oceania">Oceania</option>
</select>
</div>
<div class="toggle-group">
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
<label class="toggle-label" for="startNow">Start session immediately</label>
</div>
</form>
</main>
<footer class="footer">
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
Back
</button>
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
Next
</button>
</footer>
</body></html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1,52 +0,0 @@
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Server Details</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="4">
<header class="header">
<div class="step-indicator" data-indicator="server-details">
<span>Step</span>
<span class="current">4</span>
<span>of 18</span>
<span></span>
<span>Server Details</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Server Details</h1>
<form id="server-details-form">
<div class="form-group">
<label class="form-label" for="region">Server Region</label>
<select id="region" class="form-select" data-dropdown="region">
<option value="us-east">US East</option>
<option value="us-west">US West</option>
<option value="eu-central">EU Central</option>
<option value="eu-west">EU West</option>
<option value="asia">Asia</option>
<option value="oceania">Oceania</option>
</select>
</div>
<div class="toggle-group">
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
<label class="toggle-label" for="startNow">Start session immediately</label>
</div>
</form>
</main>
<footer class="footer">
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
Back
</button>
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
Next
</button>
</footer>
</body></html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1,72 @@
# Wizard Auto-Skip Detection - Implementation Guide
## Problem
iRacing wizard auto-skips steps 8-10 when defaults are acceptable, causing Step 8→11 jump that breaks automation validation.
## Solution Architecture
### 3 Core Methods (Infrastructure Layer Only)
**1. Detection** - `detectActualWizardPage(): Promise<number | null>`
```typescript
// Check which #set-* container exists
const mapping = {
'#set-cars': 8, '#set-track': 11, '#set-time-limit': 7,
// ... other steps
};
// Return step number of first found container
```
**2. Synchronization** - `synchronizeStepCounter(expected: number): Promise<StepSyncResult>`
```typescript
const actual = await this.detectActualWizardPage();
if (actual > expected) {
return {
skippedSteps: [expected...actual-1], // e.g., [8,9,10]
actualStep: actual
};
}
```
**3. Execution Integration** - Modify `executeStep()`
```typescript
async executeStep(stepId: StepId, config) {
if (this.isRealMode()) {
const sync = await this.synchronizeStepCounter(step);
if (sync.skippedSteps.length > 0) {
sync.skippedSteps.forEach(s => this.handleSkippedStep(s)); // Log only
return this.executeStepLogic(sync.actualStep, config);
}
}
return this.executeStepLogic(step, config);
}
```
## TDD Plan (4 Phases)
1. **Unit**: Test detection returns correct step number
2. **Unit**: Test sync calculates skipped steps correctly
3. **Integration**: Test executeStep handles skips
4. **E2E**: Verify real wizard behavior
## Key Decisions
| Aspect | Choice | Why |
|--------|--------|-----|
| **Detection** | Container existence | Fast, reliable, already mapped |
| **Timing** | Pre-execution | Clean separation, testable |
| **Skip Handling** | Log + no-op | Wizard handled it, no validation needed |
| **Layer** | Infrastructure only | Playwright-specific |
## Success Criteria
- ✅ Step 8→11 skip detected and handled
- ✅ All existing tests pass unchanged
- ✅ Detection <50ms overhead
- ✅ Clear logging for debugging
## Files Modified
- `PlaywrightAutomationAdapter.ts` (3 new methods + executeStep modification)
- Tests: 3 new test files (unit, integration, E2E)
---
*Complete design: [`WIZARD_AUTO_SKIP_DESIGN.md`](./WIZARD_AUTO_SKIP_DESIGN.md)*

1095
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,9 @@
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:e2e:docker": "vitest run --config vitest.e2e.config.ts tests/e2e/docker/",
"test:watch": "vitest watch",
"test:smoke": "vitest run --config vitest.smoke.config.ts",
"test:smoke:watch": "vitest watch --config vitest.smoke.config.ts",
"test:smoke:electron": "playwright test --config=playwright.smoke.config.ts",
"typecheck": "tsc --noEmit",
"companion": "npm run companion:build --workspace=@gridpilot/companion && npm run start --workspace=@gridpilot/companion",
"companion:dev": "npm run dev --workspace=@gridpilot/companion",
@@ -28,14 +31,22 @@
"docker:e2e:up": "docker-compose -f docker/docker-compose.e2e.yml up -d",
"docker:e2e:down": "docker-compose -f docker/docker-compose.e2e.yml down",
"generate-templates": "npx tsx scripts/generate-templates/index.ts",
"extract-fixtures": "npx tsx scripts/extract-mock-fixtures.ts",
"extract-fixtures:force": "npx tsx scripts/extract-mock-fixtures.ts --force --validate",
"prepare": "husky"
},
"devDependencies": {
"@cucumber/cucumber": "^11.0.1",
"@playwright/test": "^1.40.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@vitest/ui": "^2.1.8",
"cheerio": "^1.0.0",
"commander": "^11.0.0",
"husky": "^9.1.7",
"jsdom": "^27.2.0",
"playwright": "^1.40.0",
"prettier": "^3.0.0",
"puppeteer": "^24.31.0",
"tsx": "^4.7.0",
"typescript": "^5.7.2",

View File

@@ -1,9 +1,10 @@
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from '../../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
@@ -13,7 +14,7 @@ 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>>;
@@ -22,7 +23,7 @@ export interface IAuthenticationService {
* 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>>;
@@ -30,7 +31,7 @@ export interface IAuthenticationService {
/**
* Clear the persistent session (logout).
* Removes stored browser context and cookies.
*
*
* @returns Result indicating success or failure
*/
clearSession(): Promise<Result<void>>;
@@ -38,8 +39,38 @@ export interface IAuthenticationService {
/**
* 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,21 @@
import { Result } from '../../shared/result/Result';
import { CheckoutConfirmation } from '../../domain/value-objects/CheckoutConfirmation';
import { CheckoutPrice } from '../../domain/value-objects/CheckoutPrice';
import { CheckoutState } from '../../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 '../../domain/value-objects/CheckoutPrice';
import { CheckoutState } from '../../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,3 @@
export interface IUserConfirmationPort {
confirm(message: string): Promise<boolean>;
}

View File

@@ -1,22 +1,98 @@
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
import { Result } from '../../shared/result/Result';
import type { IAuthenticationService } from '../ports/IAuthenticationService';
import { SessionLifetime } from '../../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) {}
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(): Promise<Result<AuthenticationState>> {
return this.authService.checkSession();
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,38 @@
import { Result } from '../../shared/result/Result';
import { RaceCreationResult } from '../../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 '../../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,37 @@
import { IAuthenticationService } from '../ports/IAuthenticationService';
import { Result } from '../../shared/result/Result';
import { BrowserAuthenticationState } from '../../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()) {
return Result.err(result.error);
}
const browserState = result.unwrap();
// Log verification result
if (browserState.isFullyAuthenticated()) {
// Success case - no logging needed in use case
} else if (browserState.requiresReauthentication()) {
// Requires re-auth - caller should handle
}
return Result.ok(browserState);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Result.err(new Error(`Page verification failed: ${message}`));
}
}
}

View File

@@ -0,0 +1,91 @@
import { Result } from '../../shared/result/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)}`)
);
}
}
}

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

@@ -4,7 +4,9 @@ export type SessionStateValue =
| 'PAUSED'
| 'COMPLETED'
| 'FAILED'
| 'STOPPED_AT_STEP_18';
| 'STOPPED_AT_STEP_18'
| 'AWAITING_CHECKOUT_CONFIRMATION'
| 'CANCELLED';
const VALID_STATES: SessionStateValue[] = [
'PENDING',
@@ -13,15 +15,19 @@ const VALID_STATES: SessionStateValue[] = [
'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'],
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 {
@@ -66,6 +72,14 @@ export class SessionState {
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);
@@ -75,7 +89,8 @@ export class SessionState {
return (
this._value === 'COMPLETED' ||
this._value === 'FAILED' ||
this._value === 'STOPPED_AT_STEP_18'
this._value === 'STOPPED_AT_STEP_18' ||
this._value === 'CANCELLED'
);
}
}

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';

View File

@@ -59,4 +59,20 @@ export class Result<T, E = Error> {
}
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,43 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright configuration for Electron smoke tests
*
* Purpose: Verify Electron app launches without runtime errors
* Scope: App initialization, IPC channels, browser context isolation
*
* Critical Detection:
* - Node.js modules imported in renderer process
* - Console errors during startup
* - IPC channel communication failures
*/
export default defineConfig({
testDir: './tests/smoke',
testMatch: ['**/electron-build.smoke.test.ts'],
// Serial execution, single worker for deterministic Electron testing
fullyParallel: false,
workers: 1,
// Fail fast - stop on first error
maxFailures: 1,
// Timeout: Electron app should launch quickly
timeout: 30_000,
// Retain artifacts on failure for debugging
use: {
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
},
// Reporter: verbose for CI/local debugging
reporter: [
['list'],
['html', { open: 'never' }]
],
// No retry - smoke tests must pass on first run
retries: 0,
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,65 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Add Admin</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="6" data-modal="true">
<div class="modal-overlay">
<div class="modal-content">
<header class="modal-header">
<h2 class="modal-title" data-indicator="add-admin">Add an Admin</h2>
</header>
<div class="modal-body">
<div class="search-group">
<input
type="text"
class="search-input"
data-field="adminSearch"
placeholder="Search for admin by name..."
/>
</div>
<div class="list-container" data-list="adminResults">
<div class="list-item" data-item="admin-001">
<span>John Smith</span>
<button type="button" class="btn btn-secondary" data-action="select">Select</button>
</div>
<div class="list-item" data-item="admin-002">
<span>Jane Doe</span>
<button type="button" class="btn btn-secondary" data-action="select">Select</button>
</div>
<div class="list-item" data-item="admin-003">
<span>Bob Wilson</span>
<button type="button" class="btn btn-secondary" data-action="select">Select</button>
</div>
</div>
</div>
<footer class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-action="cancel"
onclick="window.location.href='step-05-server-details.html'"
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
data-action="confirm"
onclick="window.location.href='step-05-server-details.html'"
>
Add Selected
</button>
</footer>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,60 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Set Cars</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="8">
<header class="header">
<div class="step-indicator" data-indicator="set-cars">
<span>Step</span>
<span class="current">8</span>
<span>of 18</span>
<span></span>
<span>Set Cars</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Set Cars</h1>
<p style="color: #888; margin-bottom: 16px;">Select the cars available for this session.</p>
<div class="list-container" data-list="cars">
<div class="list-empty">No cars selected yet</div>
</div>
<button
type="button"
class="btn btn-secondary"
data-modal-trigger="car"
onclick="window.location.href='step-10-add-car.html'"
>
Add Car
</button>
</main>
<footer class="footer">
<button
type="button"
class="btn btn-secondary"
data-action="back"
onclick="window.location.href='step-06-set-admins.html'"
>
Back
</button>
<button
type="button"
class="btn btn-primary"
data-action="next"
onclick="window.location.href='step-09-set-cars.html'"
>
Next
</button>
</footer>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,68 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Set Car Classes</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="10">
<header class="header">
<div class="step-indicator" data-indicator="car-classes">
<span>Step</span>
<span class="current">10</span>
<span>of 18</span>
<span></span>
<span>Set Car Classes</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Set Car Classes</h1>
<p style="color: #888; margin-bottom: 16px;">Configure multi-class race settings.</p>
<form id="car-classes-form">
<div class="form-group">
<label class="form-label" for="carClass">Car Class</label>
<select
id="carClass"
class="form-select"
data-dropdown="carClass"
>
<option value="gt3">GT3</option>
<option value="gt4">GT4</option>
<option value="lmp2">LMP2</option>
<option value="lmp3">LMP3</option>
<option value="prototype">Prototype</option>
</select>
</div>
<div class="list-container" data-list="classAssignments">
<div class="list-empty">No class assignments yet</div>
</div>
</form>
</main>
<footer class="footer">
<button
type="button"
class="btn btn-secondary"
data-action="back"
onclick="window.location.href='step-08-time-limits.html'"
>
Back
</button>
<button
type="button"
class="btn btn-primary"
data-action="next"
onclick="window.location.href='step-11-set-car-classes.html'"
>
Next
</button>
</footer>
</body>
</html>

View File

@@ -1,69 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Add Car</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="9" data-modal="true">
<div class="modal-overlay">
<div class="modal-content">
<header class="modal-header">
<h2 class="modal-title" data-indicator="add-car">Add a Car</h2>
</header>
<div class="modal-body">
<div class="search-group">
<input
type="text"
class="search-input"
data-field="carSearch"
placeholder="Search for cars..."
/>
</div>
<div class="grid-list" data-list="carResults">
<div class="grid-item" data-item="car-001">
<strong>Porsche 911 GT3 R</strong>
<span style="color: #888; font-size: 12px;">GT3</span>
</div>
<div class="grid-item" data-item="car-002">
<strong>Ferrari 488 GT3</strong>
<span style="color: #888; font-size: 12px;">GT3</span>
</div>
<div class="grid-item" data-item="car-003">
<strong>BMW M4 GT3</strong>
<span style="color: #888; font-size: 12px;">GT3</span>
</div>
<div class="grid-item" data-item="car-004">
<strong>Mercedes AMG GT3</strong>
<span style="color: #888; font-size: 12px;">GT3</span>
</div>
</div>
</div>
<footer class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-action="cancel"
onclick="window.location.href='step-08-time-limits.html'"
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
data-action="confirm"
onclick="window.location.href='step-08-time-limits.html'"
>
Add Selected
</button>
</footer>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,61 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Set Track</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="11">
<header class="header">
<div class="step-indicator" data-indicator="set-track">
<span>Step</span>
<span class="current">11</span>
<span>of 18</span>
<span></span>
<span>Set Track</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Set Track</h1>
<p style="color: #888; margin-bottom: 16px;">Select the track for this session.</p>
<div class="form-group">
<label class="form-label">Selected Track</label>
<div class="display-field" data-field="selectedTrack">No track selected</div>
</div>
<button
type="button"
class="btn btn-secondary"
data-modal-trigger="track"
onclick="window.location.href='step-13-add-track.html'"
>
Select Track
</button>
</main>
<footer class="footer">
<button
type="button"
class="btn btn-secondary"
data-action="back"
onclick="window.location.href='step-09-set-cars.html'"
>
Back
</button>
<button
type="button"
class="btn btn-primary"
data-action="next"
onclick="window.location.href='step-12-set-track.html'"
>
Next
</button>
</footer>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,71 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Track Options</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="13">
<header class="header">
<div class="step-indicator" data-indicator="track-options">
<span>Step</span>
<span class="current">13</span>
<span>of 18</span>
<span></span>
<span>Track Options</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Track Options</h1>
<form id="track-options-form">
<div class="form-group">
<label class="form-label" for="trackConfig">Track Configuration</label>
<select
id="trackConfig"
class="form-select"
data-dropdown="trackConfig"
>
<option value="full">Full Course</option>
<option value="short">Short Course</option>
<option value="oval">Oval</option>
<option value="rallycross">Rallycross</option>
</select>
</div>
<div class="toggle-group">
<input
type="checkbox"
id="dynamicTrack"
class="toggle-input"
data-toggle="dynamicTrack"
/>
<label class="toggle-label" for="dynamicTrack">Dynamic track</label>
</div>
</form>
</main>
<footer class="footer">
<button
type="button"
class="btn btn-secondary"
data-action="back"
onclick="window.location.href='step-11-set-car-classes.html'"
>
Back
</button>
<button
type="button"
class="btn btn-primary"
data-action="next"
onclick="window.location.href='step-14-track-options.html'"
>
Next
</button>
</footer>
</body>
</html>

View File

@@ -1,69 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Add Track</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="12" data-modal="true">
<div class="modal-overlay">
<div class="modal-content">
<header class="modal-header">
<h2 class="modal-title" data-indicator="add-track">Add a Track</h2>
</header>
<div class="modal-body">
<div class="search-group">
<input
type="text"
class="search-input"
data-field="trackSearch"
placeholder="Search for tracks..."
/>
</div>
<div class="grid-list" data-list="trackResults">
<div class="grid-item" data-item="track-001">
<strong>Spa-Francorchamps</strong>
<span style="color: #888; font-size: 12px;">Belgium</span>
</div>
<div class="grid-item" data-item="track-002">
<strong>Nürburgring</strong>
<span style="color: #888; font-size: 12px;">Germany</span>
</div>
<div class="grid-item" data-item="track-003">
<strong>Daytona International</strong>
<span style="color: #888; font-size: 12px;">USA</span>
</div>
<div class="grid-item" data-item="track-004">
<strong>Suzuka Circuit</strong>
<span style="color: #888; font-size: 12px;">Japan</span>
</div>
</div>
</div>
<footer class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-action="cancel"
onclick="window.location.href='step-11-set-car-classes.html'"
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
data-action="confirm"
onclick="window.location.href='step-11-set-car-classes.html'"
>
Select
</button>
</footer>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,82 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Time of Day</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="14">
<header class="header">
<div class="step-indicator" data-indicator="time-of-day">
<span>Step</span>
<span class="current">14</span>
<span>of 18</span>
<span></span>
<span>Time of Day</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Time of Day</h1>
<form id="time-of-day-form">
<div class="slider-group">
<div class="slider-header">
<span class="slider-label">Time of Day</span>
<span class="slider-value">12:00</span>
</div>
<input
type="range"
class="slider-input"
data-slider="timeOfDay"
min="0"
max="24"
value="12"
/>
</div>
<div class="form-group">
<label class="form-label" for="raceDate">Race Date</label>
<input
type="date"
id="raceDate"
class="form-input"
data-field="raceDate"
/>
</div>
<div class="toggle-group">
<input
type="checkbox"
id="simulatedTime"
class="toggle-input"
data-toggle="simulatedTime"
/>
<label class="toggle-label" for="simulatedTime">Simulated time progression</label>
</div>
</form>
</main>
<footer class="footer">
<button
type="button"
class="btn btn-secondary"
data-action="back"
onclick="window.location.href='step-12-set-track.html'"
>
Back
</button>
<button
type="button"
class="btn btn-primary"
data-action="next"
onclick="window.location.href='step-15-time-of-day.html'"
>
Next
</button>
</footer>
</body>
</html>

View File

@@ -1,101 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Weather</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="15">
<header class="header">
<div class="step-indicator" data-indicator="weather">
<span>Step</span>
<span class="current">15</span>
<span>of 18</span>
<span></span>
<span>Weather</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Weather</h1>
<form id="weather-form">
<div class="form-group">
<label class="form-label" for="weatherType">Weather Type</label>
<select
id="weatherType"
class="form-select"
data-dropdown="weatherType"
>
<option value="clear">Clear</option>
<option value="partly-cloudy">Partly Cloudy</option>
<option value="mostly-cloudy">Mostly Cloudy</option>
<option value="overcast">Overcast</option>
</select>
</div>
<div class="slider-group">
<div class="slider-header">
<span class="slider-label">Temperature</span>
<span class="slider-value">20°C</span>
</div>
<input
type="range"
class="slider-input"
data-slider="temperature"
min="-10"
max="45"
value="20"
/>
</div>
<div class="slider-group">
<div class="slider-header">
<span class="slider-label">Humidity</span>
<span class="slider-value">50%</span>
</div>
<input
type="range"
class="slider-input"
data-slider="humidity"
min="0"
max="100"
value="50"
/>
</div>
<div class="toggle-group">
<input
type="checkbox"
id="dynamicWeather"
class="toggle-input"
data-toggle="dynamicWeather"
/>
<label class="toggle-label" for="dynamicWeather">Dynamic weather</label>
</div>
</form>
</main>
<footer class="footer">
<button
type="button"
class="btn btn-secondary"
data-action="back"
onclick="window.location.href='step-14-track-options.html'"
>
Back
</button>
<button
type="button"
class="btn btn-primary"
data-action="next"
onclick="window.location.href='step-16-weather.html'"
>
Next
</button>
</footer>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,90 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Race Options</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="16">
<header class="header">
<div class="step-indicator" data-indicator="race-options">
<span>Step</span>
<span class="current">16</span>
<span>of 18</span>
<span></span>
<span>Race Options</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Race Options</h1>
<form id="race-options-form">
<div class="form-group">
<label class="form-label" for="maxDrivers">Maximum Drivers</label>
<input
type="number"
id="maxDrivers"
class="form-input"
data-field="maxDrivers"
value="32"
min="1"
max="60"
/>
</div>
<div class="toggle-group">
<input
type="checkbox"
id="rollingStart"
class="toggle-input"
data-toggle="rollingStart"
/>
<label class="toggle-label" for="rollingStart">Rolling start</label>
</div>
<div class="toggle-group">
<input
type="checkbox"
id="fullCourseCautions"
class="toggle-input"
data-toggle="fullCourseCautions"
/>
<label class="toggle-label" for="fullCourseCautions">Full course cautions</label>
</div>
<div class="toggle-group">
<input
type="checkbox"
id="fastRepairs"
class="toggle-input"
data-toggle="fastRepairs"
/>
<label class="toggle-label" for="fastRepairs">Fast repairs</label>
</div>
</form>
</main>
<footer class="footer">
<button
type="button"
class="btn btn-secondary"
data-action="back"
onclick="window.location.href='step-15-time-of-day.html'"
>
Back
</button>
<button
type="button"
class="btn btn-primary"
data-action="next"
onclick="window.location.href='step-17-race-options.html'"
>
Next
</button>
</footer>
</body>
</html>

View File

@@ -1,83 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iRacing - Team Driving</title>
<link rel="stylesheet" href="common.css">
</head>
<body data-step="17">
<header class="header">
<div class="step-indicator" data-indicator="team-driving">
<span>Step</span>
<span class="current">17</span>
<span>of 18</span>
<span></span>
<span>Team Driving</span>
</div>
</header>
<main class="main">
<h1 class="page-title">Team Driving</h1>
<form id="team-driving-form">
<div class="toggle-group">
<input
type="checkbox"
id="teamDriving"
class="toggle-input"
data-toggle="teamDriving"
/>
<label class="toggle-label" for="teamDriving">Enable team driving</label>
</div>
<div class="form-group">
<label class="form-label" for="minDrivers">Min Drivers per Team</label>
<input
type="number"
id="minDrivers"
class="form-input"
data-field="minDrivers"
value="1"
min="1"
max="16"
/>
</div>
<div class="form-group">
<label class="form-label" for="maxDriversTeam">Max Drivers per Team</label>
<input
type="number"
id="maxDriversTeam"
class="form-input"
data-field="maxDrivers"
value="4"
min="1"
max="16"
/>
</div>
</form>
</main>
<footer class="footer">
<button
type="button"
class="btn btn-secondary"
data-action="back"
onclick="window.location.href='step-16-weather.html'"
>
Back
</button>
<button
type="button"
class="btn btn-primary"
data-action="next"
onclick="window.location.href='step-18-track-conditions.html'"
>
Next
</button>
</footer>
</body>
</html>

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More