wip
3
.browser-config.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"mode": "headless"
|
||||||
|
}
|
||||||
5
.gitignore
vendored
@@ -10,6 +10,11 @@ dist/
|
|||||||
build/
|
build/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
debug-screenshots
|
||||||
|
test-user-data
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 448 KiB |
|
Before Width: | Height: | Size: 389 KiB |
|
Before Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 389 KiB |
|
Before Width: | Height: | Size: 388 KiB |
@@ -8,7 +8,8 @@ import { StartAutomationSessionUseCase } from '@/packages/application/use-cases/
|
|||||||
import { CheckAuthenticationUseCase } from '@/packages/application/use-cases/CheckAuthenticationUseCase';
|
import { CheckAuthenticationUseCase } from '@/packages/application/use-cases/CheckAuthenticationUseCase';
|
||||||
import { InitiateLoginUseCase } from '@/packages/application/use-cases/InitiateLoginUseCase';
|
import { InitiateLoginUseCase } from '@/packages/application/use-cases/InitiateLoginUseCase';
|
||||||
import { ClearSessionUseCase } from '@/packages/application/use-cases/ClearSessionUseCase';
|
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 { PinoLogAdapter } from '@/packages/infrastructure/adapters/logging/PinoLogAdapter';
|
||||||
import { NoOpLogAdapter } from '@/packages/infrastructure/adapters/logging/NoOpLogAdapter';
|
import { NoOpLogAdapter } from '@/packages/infrastructure/adapters/logging/NoOpLogAdapter';
|
||||||
import { loadLoggingConfig } from '@/packages/infrastructure/config/LoggingConfig';
|
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 { IScreenAutomation } from '@/packages/application/ports/IScreenAutomation';
|
||||||
import type { IAutomationEngine } from '@/packages/application/ports/IAutomationEngine';
|
import type { IAutomationEngine } from '@/packages/application/ports/IAutomationEngine';
|
||||||
import type { IAuthenticationService } from '@/packages/application/ports/IAuthenticationService';
|
import type { IAuthenticationService } from '@/packages/application/ports/IAuthenticationService';
|
||||||
|
import type { ICheckoutConfirmationPort } from '@/packages/application/ports/ICheckoutConfirmationPort';
|
||||||
import type { ILogger } from '@/packages/application/ports/ILogger';
|
import type { ILogger } from '@/packages/application/ports/ILogger';
|
||||||
|
|
||||||
export interface BrowserConnectionResult {
|
export interface BrowserConnectionResult {
|
||||||
@@ -92,7 +94,11 @@ function getAdapterMode(envMode: AutomationMode): AutomationAdapterMode {
|
|||||||
* @param logger - Logger instance for the adapter
|
* @param logger - Logger instance for the adapter
|
||||||
* @returns PlaywrightAutomationAdapter instance (implements both IScreenAutomation and IAuthenticationService)
|
* @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();
|
const config = loadAutomationConfig();
|
||||||
|
|
||||||
// Resolve absolute template path for Electron environment
|
// Resolve absolute template path for Electron environment
|
||||||
@@ -108,18 +114,28 @@ function createBrowserAutomationAdapter(mode: AutomationMode, logger: ILogger):
|
|||||||
});
|
});
|
||||||
|
|
||||||
const adapterMode = getAdapterMode(mode);
|
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) {
|
switch (mode) {
|
||||||
case 'production':
|
case 'production':
|
||||||
case 'development':
|
case 'development':
|
||||||
return new PlaywrightAutomationAdapter(
|
return new PlaywrightAutomationAdapter(
|
||||||
{
|
{
|
||||||
headless: mode === 'production',
|
headless: browserModeConfig.mode === 'headless',
|
||||||
mode: adapterMode,
|
mode: adapterMode,
|
||||||
userDataDir: sessionDataPath,
|
userDataDir: sessionDataPath,
|
||||||
},
|
},
|
||||||
logger.child({ adapter: 'Playwright', mode: adapterMode })
|
logger.child({ adapter: 'Playwright', mode: adapterMode }),
|
||||||
|
browserModeConfigLoader
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'test':
|
case 'test':
|
||||||
@@ -139,7 +155,9 @@ export class DIContainer {
|
|||||||
private checkAuthenticationUseCase: CheckAuthenticationUseCase | null = null;
|
private checkAuthenticationUseCase: CheckAuthenticationUseCase | null = null;
|
||||||
private initiateLoginUseCase: InitiateLoginUseCase | null = null;
|
private initiateLoginUseCase: InitiateLoginUseCase | null = null;
|
||||||
private clearSessionUseCase: ClearSessionUseCase | null = null;
|
private clearSessionUseCase: ClearSessionUseCase | null = null;
|
||||||
|
private confirmCheckoutUseCase: ConfirmCheckoutUseCase | null = null;
|
||||||
private automationMode: AutomationMode;
|
private automationMode: AutomationMode;
|
||||||
|
private browserModeConfigLoader: BrowserModeConfigLoader;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
// Initialize logger first - it's needed by other components
|
// Initialize logger first - it's needed by other components
|
||||||
@@ -153,8 +171,15 @@ export class DIContainer {
|
|||||||
|
|
||||||
const config = loadAutomationConfig();
|
const config = loadAutomationConfig();
|
||||||
|
|
||||||
|
// Initialize browser mode config loader as singleton
|
||||||
|
this.browserModeConfigLoader = new BrowserModeConfigLoader();
|
||||||
|
|
||||||
this.sessionRepository = new InMemorySessionRepository();
|
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.automationEngine = new MockAutomationEngineAdapter(
|
||||||
this.browserAutomation,
|
this.browserAutomation,
|
||||||
this.sessionRepository
|
this.sessionRepository
|
||||||
@@ -241,6 +266,21 @@ export class DIContainer {
|
|||||||
return null;
|
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.
|
* Initialize automation connection based on mode.
|
||||||
* In production/development mode, connects via Playwright browser automation.
|
* In production/development mode, connects via Playwright browser automation.
|
||||||
@@ -292,6 +332,14 @@ export class DIContainer {
|
|||||||
this.logger.info('DIContainer shutdown complete');
|
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).
|
* Reset the singleton instance (useful for testing with different configurations).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { DIContainer } from './di-container';
|
|||||||
import type { HostedSessionConfig } from '@/packages/domain/entities/HostedSessionConfig';
|
import type { HostedSessionConfig } from '@/packages/domain/entities/HostedSessionConfig';
|
||||||
import { StepId } from '@/packages/domain/value-objects/StepId';
|
import { StepId } from '@/packages/domain/value-objects/StepId';
|
||||||
import { AuthenticationState } from '@/packages/domain/value-objects/AuthenticationState';
|
import { AuthenticationState } from '@/packages/domain/value-objects/AuthenticationState';
|
||||||
|
import { ElectronCheckoutConfirmationAdapter } from '@/packages/infrastructure/adapters/ipc/ElectronCheckoutConfirmationAdapter';
|
||||||
|
|
||||||
let progressMonitorInterval: NodeJS.Timeout | null = null;
|
let progressMonitorInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
@@ -14,6 +15,10 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
|||||||
const automationEngine = container.getAutomationEngine();
|
const automationEngine = container.getAutomationEngine();
|
||||||
const logger = container.getLogger();
|
const logger = container.getLogger();
|
||||||
|
|
||||||
|
// Setup checkout confirmation adapter and wire it into the container
|
||||||
|
const checkoutConfirmationAdapter = new ElectronCheckoutConfirmationAdapter(mainWindow);
|
||||||
|
container.setConfirmCheckoutUseCase(checkoutConfirmationAdapter);
|
||||||
|
|
||||||
// Authentication handlers
|
// Authentication handlers
|
||||||
ipcMain.handle('auth:check', async () => {
|
ipcMain.handle('auth:check', async () => {
|
||||||
try {
|
try {
|
||||||
@@ -21,11 +26,10 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
|||||||
const checkAuthUseCase = container.getCheckAuthenticationUseCase();
|
const checkAuthUseCase = container.getCheckAuthenticationUseCase();
|
||||||
|
|
||||||
if (!checkAuthUseCase) {
|
if (!checkAuthUseCase) {
|
||||||
logger.warn('Authentication not available in mock mode');
|
logger.error('Authentication use case not available');
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: false,
|
||||||
state: AuthenticationState.AUTHENTICATED,
|
error: 'Authentication not available - check system configuration'
|
||||||
message: 'Mock mode - authentication bypassed'
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,17 @@ export interface AuthActionResponse {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CheckoutConfirmationRequest {
|
||||||
|
price: string;
|
||||||
|
state: 'ready' | 'insufficient_funds';
|
||||||
|
sessionMetadata: {
|
||||||
|
sessionName: string;
|
||||||
|
trackId: string;
|
||||||
|
carIds: string[];
|
||||||
|
};
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
startAutomation: (config: HostedSessionConfig) => Promise<{
|
startAutomation: (config: HostedSessionConfig) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -37,6 +48,12 @@ export interface ElectronAPI {
|
|||||||
initiateLogin: () => Promise<AuthActionResponse>;
|
initiateLogin: () => Promise<AuthActionResponse>;
|
||||||
confirmLogin: () => Promise<AuthActionResponse>;
|
confirmLogin: () => Promise<AuthActionResponse>;
|
||||||
logout: () => 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', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
@@ -56,4 +73,18 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
initiateLogin: () => ipcRenderer.invoke('auth:login'),
|
initiateLogin: () => ipcRenderer.invoke('auth:login'),
|
||||||
confirmLogin: () => ipcRenderer.invoke('auth:confirmLogin'),
|
confirmLogin: () => ipcRenderer.invoke('auth:confirmLogin'),
|
||||||
logout: () => ipcRenderer.invoke('auth:logout'),
|
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);
|
} as ElectronAPI);
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"main": "dist/main/main.cjs",
|
"main": "dist/main/main.cjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"build": "electron-vite build",
|
||||||
"preview": "unset ELECTRON_RUN_AS_NODE && electron-vite preview",
|
"preview": "unset ELECTRON_RUN_AS_NODE && electron-vite preview",
|
||||||
"start": "unset ELECTRON_RUN_AS_NODE && electron ."
|
"start": "unset ELECTRON_RUN_AS_NODE && electron ."
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|||||||
import { SessionCreationForm } from './components/SessionCreationForm';
|
import { SessionCreationForm } from './components/SessionCreationForm';
|
||||||
import { SessionProgressMonitor } from './components/SessionProgressMonitor';
|
import { SessionProgressMonitor } from './components/SessionProgressMonitor';
|
||||||
import { LoginPrompt } from './components/LoginPrompt';
|
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';
|
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
|
||||||
|
|
||||||
interface SessionProgress {
|
interface SessionProgress {
|
||||||
@@ -24,6 +27,26 @@ export function App() {
|
|||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [loginStatus, setLoginStatus] = useState<LoginStatus>('idle');
|
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 () => {
|
const handleLogin = useCallback(async () => {
|
||||||
if (!window.electronAPI) return;
|
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();
|
checkAuth();
|
||||||
|
|
||||||
window.electronAPI.onSessionProgress((newProgress: SessionProgress) => {
|
window.electronAPI.onSessionProgress((newProgress: SessionProgress) => {
|
||||||
@@ -101,6 +129,11 @@ export function App() {
|
|||||||
setIsRunning(false);
|
setIsRunning(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cleanup subscription on unmount
|
||||||
|
return () => {
|
||||||
|
unsubscribeCheckout?.();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleStartAutomation = async (config: HostedSessionConfig) => {
|
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') {
|
if (authState !== 'AUTHENTICATED') {
|
||||||
return (
|
return (
|
||||||
<LoginPrompt
|
<LoginPrompt
|
||||||
@@ -178,37 +221,42 @@ export function App() {
|
|||||||
<div style={{
|
<div style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: '2rem',
|
padding: '2rem',
|
||||||
borderRight: '1px solid #333'
|
borderRight: '1px solid #333',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
}}>
|
}}>
|
||||||
<h1 style={{ marginBottom: '2rem', color: '#fff' }}>
|
<div style={{ flex: 1 }}>
|
||||||
GridPilot Companion
|
<h1 style={{ marginBottom: '2rem', color: '#fff' }}>
|
||||||
</h1>
|
GridPilot Companion
|
||||||
<p style={{ marginBottom: '2rem', color: '#aaa' }}>
|
</h1>
|
||||||
Hosted Session Automation POC
|
<p style={{ marginBottom: '2rem', color: '#aaa' }}>
|
||||||
</p>
|
Hosted Session Automation POC
|
||||||
|
</p>
|
||||||
|
|
||||||
<SessionCreationForm
|
<SessionCreationForm
|
||||||
onSubmit={handleStartAutomation}
|
onSubmit={handleStartAutomation}
|
||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
/>
|
/>
|
||||||
{isRunning && (
|
{isRunning && (
|
||||||
<button
|
<button
|
||||||
onClick={handleStopAutomation}
|
onClick={handleStopAutomation}
|
||||||
style={{
|
style={{
|
||||||
marginTop: '1rem',
|
marginTop: '1rem',
|
||||||
padding: '0.75rem 1.5rem',
|
padding: '0.75rem 1.5rem',
|
||||||
backgroundColor: '#dc3545',
|
backgroundColor: '#dc3545',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '1rem',
|
fontSize: '1rem',
|
||||||
fontWeight: 'bold'
|
fontWeight: 'bold'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Stop Automation
|
Stop Automation
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<BrowserModeToggle />
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
40
apps/companion/renderer/components/BrowserModeToggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -32,8 +32,7 @@ const STEP_NAMES: { [key: number]: string } = {
|
|||||||
14: 'Set Time of Day',
|
14: 'Set Time of Day',
|
||||||
15: 'Configure Weather',
|
15: 'Configure Weather',
|
||||||
16: 'Set Race Options',
|
16: 'Set Race Options',
|
||||||
17: 'Configure Team Driving',
|
17: 'Set Track Conditions'
|
||||||
18: 'Set Track Conditions'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SessionProgressMonitor({ sessionId, progress, isRunning }: SessionProgressMonitorProps) {
|
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' }}>
|
<div style={{ marginBottom: '1rem', color: '#aaa', fontSize: '14px' }}>
|
||||||
Progress: {progress.completedSteps.length} / 18 steps
|
Progress: {progress.completedSteps.length} / 17 steps
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 19 KiB |
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 19 KiB |
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 19 KiB |
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 19 KiB |
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 19 KiB |
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 19 KiB |
72
docs/WIZARD_AUTO_SKIP_SUMMARY.md
Normal 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
11
package.json
@@ -19,6 +19,9 @@
|
|||||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||||
"test:e2e:docker": "vitest run --config vitest.e2e.config.ts tests/e2e/docker/",
|
"test:e2e:docker": "vitest run --config vitest.e2e.config.ts tests/e2e/docker/",
|
||||||
"test:watch": "vitest watch",
|
"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",
|
"typecheck": "tsc --noEmit",
|
||||||
"companion": "npm run companion:build --workspace=@gridpilot/companion && npm run start --workspace=@gridpilot/companion",
|
"companion": "npm run companion:build --workspace=@gridpilot/companion && npm run start --workspace=@gridpilot/companion",
|
||||||
"companion:dev": "npm run dev --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:up": "docker-compose -f docker/docker-compose.e2e.yml up -d",
|
||||||
"docker:e2e:down": "docker-compose -f docker/docker-compose.e2e.yml down",
|
"docker:e2e:down": "docker-compose -f docker/docker-compose.e2e.yml down",
|
||||||
"generate-templates": "npx tsx scripts/generate-templates/index.ts",
|
"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"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cucumber/cucumber": "^11.0.1",
|
"@cucumber/cucumber": "^11.0.1",
|
||||||
"@playwright/test": "^1.40.0",
|
"@playwright/test": "^1.40.0",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
"@vitest/ui": "^2.1.8",
|
"@vitest/ui": "^2.1.8",
|
||||||
|
"cheerio": "^1.0.0",
|
||||||
|
"commander": "^11.0.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
|
"jsdom": "^27.2.0",
|
||||||
"playwright": "^1.40.0",
|
"playwright": "^1.40.0",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
"puppeteer": "^24.31.0",
|
"puppeteer": "^24.31.0",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
|
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
|
||||||
|
import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState';
|
||||||
import { Result } from '../../shared/result/Result';
|
import { Result } from '../../shared/result/Result';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Port for authentication services implementing zero-knowledge login.
|
* Port for authentication services implementing zero-knowledge login.
|
||||||
*
|
*
|
||||||
* GridPilot never sees, stores, or transmits user credentials.
|
* GridPilot never sees, stores, or transmits user credentials.
|
||||||
* Authentication is handled by opening a visible browser window where
|
* Authentication is handled by opening a visible browser window where
|
||||||
* the user logs in directly with iRacing. GridPilot only observes
|
* 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.
|
* Check if user has a valid session without prompting login.
|
||||||
* Navigates to a protected iRacing page and checks for login redirects.
|
* Navigates to a protected iRacing page and checks for login redirects.
|
||||||
*
|
*
|
||||||
* @returns Result containing the current authentication state
|
* @returns Result containing the current authentication state
|
||||||
*/
|
*/
|
||||||
checkSession(): Promise<Result<AuthenticationState>>;
|
checkSession(): Promise<Result<AuthenticationState>>;
|
||||||
@@ -22,7 +23,7 @@ export interface IAuthenticationService {
|
|||||||
* Open browser for user to login manually.
|
* Open browser for user to login manually.
|
||||||
* The browser window is visible so user can verify they're on the real iRacing site.
|
* The browser window is visible so user can verify they're on the real iRacing site.
|
||||||
* GridPilot waits for URL change indicating successful login.
|
* GridPilot waits for URL change indicating successful login.
|
||||||
*
|
*
|
||||||
* @returns Result indicating success (login complete) or failure (cancelled/timeout)
|
* @returns Result indicating success (login complete) or failure (cancelled/timeout)
|
||||||
*/
|
*/
|
||||||
initiateLogin(): Promise<Result<void>>;
|
initiateLogin(): Promise<Result<void>>;
|
||||||
@@ -30,7 +31,7 @@ export interface IAuthenticationService {
|
|||||||
/**
|
/**
|
||||||
* Clear the persistent session (logout).
|
* Clear the persistent session (logout).
|
||||||
* Removes stored browser context and cookies.
|
* Removes stored browser context and cookies.
|
||||||
*
|
*
|
||||||
* @returns Result indicating success or failure
|
* @returns Result indicating success or failure
|
||||||
*/
|
*/
|
||||||
clearSession(): Promise<Result<void>>;
|
clearSession(): Promise<Result<void>>;
|
||||||
@@ -38,8 +39,38 @@ export interface IAuthenticationService {
|
|||||||
/**
|
/**
|
||||||
* Get current authentication state.
|
* Get current authentication state.
|
||||||
* Returns cached state without making network requests.
|
* Returns cached state without making network requests.
|
||||||
*
|
*
|
||||||
* @returns The current AuthenticationState
|
* @returns The current AuthenticationState
|
||||||
*/
|
*/
|
||||||
getState(): 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>>;
|
||||||
}
|
}
|
||||||
21
packages/application/ports/ICheckoutConfirmationPort.ts
Normal 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>>;
|
||||||
|
}
|
||||||
14
packages/application/ports/ICheckoutService.ts
Normal 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>>;
|
||||||
|
}
|
||||||
3
packages/application/ports/IUserConfirmationPort.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export interface IUserConfirmationPort {
|
||||||
|
confirm(message: string): Promise<boolean>;
|
||||||
|
}
|
||||||
@@ -1,22 +1,98 @@
|
|||||||
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
|
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
|
||||||
import { Result } from '../../shared/result/Result';
|
import { Result } from '../../shared/result/Result';
|
||||||
import type { IAuthenticationService } from '../ports/IAuthenticationService';
|
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.
|
* Use case for checking if the user has a valid iRacing session.
|
||||||
*
|
*
|
||||||
* This validates the session before automation starts, allowing
|
* This validates the session before automation starts, allowing
|
||||||
* the system to prompt for re-authentication if needed.
|
* 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 {
|
export class CheckAuthenticationUseCase {
|
||||||
constructor(private readonly authService: IAuthenticationService) {}
|
constructor(
|
||||||
|
private readonly authService: IAuthenticationService,
|
||||||
|
private readonly sessionValidator?: ISessionValidator
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the authentication check.
|
* Execute the authentication check.
|
||||||
*
|
*
|
||||||
|
* @param options Optional configuration for validation
|
||||||
* @returns Result containing the current AuthenticationState
|
* @returns Result containing the current AuthenticationState
|
||||||
*/
|
*/
|
||||||
async execute(): Promise<Result<AuthenticationState>> {
|
async execute(options?: {
|
||||||
return this.authService.checkSession();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
packages/application/use-cases/ConfirmCheckoutUseCase.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
packages/domain/services/PageStateValidator.ts
Normal 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)}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
packages/domain/value-objects/BrowserAuthenticationState.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
packages/domain/value-objects/CheckoutConfirmation.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
49
packages/domain/value-objects/CheckoutPrice.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
packages/domain/value-objects/CheckoutState.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
packages/domain/value-objects/CookieConfiguration.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
55
packages/domain/value-objects/RaceCreationResult.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
85
packages/domain/value-objects/SessionLifetime.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ export type SessionStateValue =
|
|||||||
| 'PAUSED'
|
| 'PAUSED'
|
||||||
| 'COMPLETED'
|
| 'COMPLETED'
|
||||||
| 'FAILED'
|
| 'FAILED'
|
||||||
| 'STOPPED_AT_STEP_18';
|
| 'STOPPED_AT_STEP_18'
|
||||||
|
| 'AWAITING_CHECKOUT_CONFIRMATION'
|
||||||
|
| 'CANCELLED';
|
||||||
|
|
||||||
const VALID_STATES: SessionStateValue[] = [
|
const VALID_STATES: SessionStateValue[] = [
|
||||||
'PENDING',
|
'PENDING',
|
||||||
@@ -13,15 +15,19 @@ const VALID_STATES: SessionStateValue[] = [
|
|||||||
'COMPLETED',
|
'COMPLETED',
|
||||||
'FAILED',
|
'FAILED',
|
||||||
'STOPPED_AT_STEP_18',
|
'STOPPED_AT_STEP_18',
|
||||||
|
'AWAITING_CHECKOUT_CONFIRMATION',
|
||||||
|
'CANCELLED',
|
||||||
];
|
];
|
||||||
|
|
||||||
const VALID_TRANSITIONS: Record<SessionStateValue, SessionStateValue[]> = {
|
const VALID_TRANSITIONS: Record<SessionStateValue, SessionStateValue[]> = {
|
||||||
PENDING: ['IN_PROGRESS', 'FAILED'],
|
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'],
|
PAUSED: ['IN_PROGRESS', 'FAILED'],
|
||||||
COMPLETED: [],
|
COMPLETED: [],
|
||||||
FAILED: [],
|
FAILED: [],
|
||||||
STOPPED_AT_STEP_18: [],
|
STOPPED_AT_STEP_18: [],
|
||||||
|
AWAITING_CHECKOUT_CONFIRMATION: ['COMPLETED', 'CANCELLED', 'FAILED'],
|
||||||
|
CANCELLED: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export class SessionState {
|
export class SessionState {
|
||||||
@@ -66,6 +72,14 @@ export class SessionState {
|
|||||||
return this._value === 'STOPPED_AT_STEP_18';
|
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 {
|
canTransitionTo(targetState: SessionState): boolean {
|
||||||
const allowedTransitions = VALID_TRANSITIONS[this._value];
|
const allowedTransitions = VALID_TRANSITIONS[this._value];
|
||||||
return allowedTransitions.includes(targetState._value);
|
return allowedTransitions.includes(targetState._value);
|
||||||
@@ -75,7 +89,8 @@ export class SessionState {
|
|||||||
return (
|
return (
|
||||||
this._value === 'COMPLETED' ||
|
this._value === 'COMPLETED' ||
|
||||||
this._value === 'FAILED' ||
|
this._value === 'FAILED' ||
|
||||||
this._value === 'STOPPED_AT_STEP_18'
|
this._value === 'STOPPED_AT_STEP_18' ||
|
||||||
|
this._value === 'CANCELLED'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ export interface IFixtureServer {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Step number to fixture file mapping.
|
* 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> = {
|
const STEP_TO_FIXTURE: Record<number, string> = {
|
||||||
2: 'step-02-hosted-racing.html',
|
2: 'step-02-hosted-racing.html',
|
||||||
@@ -19,18 +19,17 @@ const STEP_TO_FIXTURE: Record<number, string> = {
|
|||||||
4: 'step-04-race-information.html',
|
4: 'step-04-race-information.html',
|
||||||
5: 'step-05-server-details.html',
|
5: 'step-05-server-details.html',
|
||||||
6: 'step-06-set-admins.html',
|
6: 'step-06-set-admins.html',
|
||||||
7: 'step-07-add-admin.html',
|
7: 'step-07-time-limits.html', // Time Limits wizard step
|
||||||
8: 'step-08-time-limits.html',
|
8: 'step-08-set-cars.html', // Set Cars wizard step
|
||||||
9: 'step-09-set-cars.html',
|
9: 'step-09-add-car-modal.html', // Add Car modal
|
||||||
10: 'step-10-add-car.html',
|
10: 'step-10-set-car-classes.html', // Set Car Classes
|
||||||
11: 'step-11-set-car-classes.html',
|
11: 'step-11-set-track.html', // Set Track wizard step (CORRECTED)
|
||||||
12: 'step-12-set-track.html',
|
12: 'step-12-add-track-modal.html', // Add Track modal
|
||||||
13: 'step-13-add-track.html',
|
13: 'step-13-track-options.html',
|
||||||
14: 'step-14-track-options.html',
|
14: 'step-14-time-of-day.html',
|
||||||
15: 'step-15-time-of-day.html',
|
15: 'step-15-weather.html',
|
||||||
16: 'step-16-weather.html',
|
16: 'step-16-race-options.html',
|
||||||
17: 'step-17-race-options.html',
|
17: 'step-17-track-conditions.html',
|
||||||
18: 'step-18-track-conditions.html',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class FixtureServer implements IFixtureServer {
|
export class FixtureServer implements IFixtureServer {
|
||||||
|
|||||||
@@ -111,26 +111,28 @@ export const IRACING_SELECTORS = {
|
|||||||
// Step 8/9: Cars
|
// Step 8/9: Cars
|
||||||
carSearch: '.wizard-sidebar input[placeholder*="Search"], #set-cars input[placeholder*="Search"], .modal input[placeholder*="Search"]',
|
carSearch: '.wizard-sidebar input[placeholder*="Search"], #set-cars input[placeholder*="Search"], .modal input[placeholder*="Search"]',
|
||||||
carList: '#set-cars [data-list="cars"]',
|
carList: '#set-cars [data-list="cars"]',
|
||||||
// Add Car button - triggers the Add Car modal
|
// Add Car button - triggers car selection interface in wizard sidebar
|
||||||
addCarButton: '#set-cars a.btn:has(.icon-plus), #set-cars button:has-text("Add"), #set-cars a.btn:has-text("Add")',
|
// CORRECTED: Added fallback selectors since .icon-plus cannot be verified in minified HTML
|
||||||
// Add Car modal - appears after clicking Add Car button
|
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")',
|
||||||
addCarModal: '#add-car-modal, .modal:has(input[placeholder*="Search"]):has-text("Car")',
|
// Car selection interface - CORRECTED: No separate modal, uses wizard sidebar within main modal
|
||||||
// Select button inside Add Car modal table row - clicking this adds the car immediately (no confirm step)
|
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
|
// 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
|
// Step 10/11/12: Track
|
||||||
trackSearch: '.wizard-sidebar input[placeholder*="Search"], #set-track input[placeholder*="Search"], .modal input[placeholder*="Search"]',
|
trackSearch: '.wizard-sidebar input[placeholder*="Search"], #set-track input[placeholder*="Search"], .modal input[placeholder*="Search"]',
|
||||||
trackList: '#set-track [data-list="tracks"]',
|
trackList: '#set-track [data-list="tracks"]',
|
||||||
// Add Track button - triggers the Add Track modal
|
// Add Track button - triggers track selection interface in wizard sidebar
|
||||||
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")',
|
// CORRECTED: Added fallback selectors since .icon-plus cannot be verified in minified HTML
|
||||||
// Add Track modal - appears after clicking Add Track button
|
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")',
|
||||||
addTrackModal: '#add-track-modal, .modal:has(input[placeholder*="Search"]):has-text("Track")',
|
// Track selection interface - CORRECTED: No separate modal, uses wizard sidebar within main modal
|
||||||
// Select button inside Add Track modal table row - clicking this selects the track immediately (no confirm step)
|
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
|
// 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
|
// 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
|
// 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',
|
trackSelectDropdownItem: '.dropdown-menu.show .dropdown-item:first-child, .dropdown-menu-lg .dropdown-item:first-child',
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { AuthenticationState } from '../../../domain/value-objects/AuthenticationState';
|
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';
|
import type { ILogger } from '../../../application/ports/ILogger';
|
||||||
|
|
||||||
interface Cookie {
|
interface Cookie {
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
path: string;
|
||||||
expires: number;
|
expires: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ const IRACING_DOMAINS = [
|
|||||||
'iracing.com',
|
'iracing.com',
|
||||||
'.iracing.com',
|
'.iracing.com',
|
||||||
'members.iracing.com',
|
'members.iracing.com',
|
||||||
|
'members-ng.iracing.com',
|
||||||
];
|
];
|
||||||
|
|
||||||
const EXPIRY_BUFFER_SECONDS = 300;
|
const EXPIRY_BUFFER_SECONDS = 300;
|
||||||
@@ -63,13 +67,23 @@ export class SessionCookieStore {
|
|||||||
async read(): Promise<StorageState | null> {
|
async read(): Promise<StorageState | null> {
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(this.storagePath, 'utf-8');
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async write(state: StorageState): Promise<void> {
|
async write(state: StorageState): Promise<void> {
|
||||||
|
this.cachedState = state;
|
||||||
await fs.writeFile(this.storagePath, JSON.stringify(state, null, 2), 'utf-8');
|
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.
|
* Validate cookies and determine authentication state.
|
||||||
*
|
*
|
||||||
@@ -192,4 +265,114 @@ export class SessionCookieStore {
|
|||||||
this.log('info', 'iRacing session cookies found but all expired');
|
this.log('info', 'iRacing session cookies found but all expired');
|
||||||
return AuthenticationState.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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
59
packages/infrastructure/config/BrowserModeConfig.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 * from './AutomationConfig';
|
||||||
export { loadAutomationConfig, getAutomationMode } from './AutomationConfig';
|
export * from './LoggingConfig';
|
||||||
|
export * from './BrowserModeConfig';
|
||||||
@@ -59,4 +59,20 @@ export class Result<T, E = Error> {
|
|||||||
}
|
}
|
||||||
return Result.err(this._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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
43
playwright.smoke.config.ts
Normal 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,
|
||||||
|
});
|
||||||
@@ -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>
|
|
||||||
2573
resources/mock-fixtures/step-07-time-limits.html
Normal file
16699
resources/mock-fixtures/step-08-set-cars.html
Normal 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>
|
|
||||||
16876
resources/mock-fixtures/step-09-add-car-modal.html
Normal 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>
|
|
||||||
@@ -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>
|
|
||||||
8859
resources/mock-fixtures/step-10-set-car-classes.html
Normal 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>
|
|
||||||
15169
resources/mock-fixtures/step-11-set-track.html
Normal file
15169
resources/mock-fixtures/step-12-add-track-modal.html
Normal 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>
|
|
||||||
@@ -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>
|
|
||||||
2717
resources/mock-fixtures/step-13-track-options.html
Normal file
2459
resources/mock-fixtures/step-14-time-of-day.html
Normal 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>
|
|
||||||
@@ -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>
|
|
||||||
3070
resources/mock-fixtures/step-15-weather.html
Normal file
2837
resources/mock-fixtures/step-16-race-options.html
Normal 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>
|
|
||||||
@@ -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>
|
|
||||||