This commit is contained in:
2025-11-30 23:00:48 +01:00
parent 4b8c70978f
commit 645f537895
41 changed files with 738 additions and 1631 deletions

View File

@@ -12,15 +12,6 @@ export default defineConfig({
}, },
rollupOptions: { rollupOptions: {
external: [ external: [
'@nut-tree-fork/nut-js',
'@nut-tree-fork/libnut',
'@nut-tree-fork/libnut-darwin',
'@nut-tree-fork/libnut-linux',
'@nut-tree-fork/libnut-win32',
'@nut-tree-fork/node-mac-permissions',
'@nut-tree-fork/default-clipboard-provider',
'@nut-tree-fork/provider-interfaces',
'@nut-tree-fork/shared',
'bufferutil', 'bufferutil',
'utf-8-validate', 'utf-8-validate',
'playwright', 'playwright',

View File

@@ -175,10 +175,10 @@ export class DIContainer {
private static instance: DIContainer; private static instance: DIContainer;
private logger: ILogger; private logger: ILogger;
private sessionRepository: ISessionRepository; private sessionRepository!: ISessionRepository;
private browserAutomation: PlaywrightAutomationAdapter | MockBrowserAutomationAdapter; private browserAutomation!: PlaywrightAutomationAdapter | MockBrowserAutomationAdapter;
private automationEngine: IAutomationEngine; private automationEngine!: IAutomationEngine;
private startAutomationUseCase: StartAutomationSessionUseCase; private startAutomationUseCase!: StartAutomationSessionUseCase;
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;

View File

@@ -372,7 +372,8 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
mainWindow.webContents.send('automation-event', ev); mainWindow.webContents.send('automation-event', ev);
} }
} catch (e) { } catch (e) {
logger.debug?.('Failed to forward automation-event', e); const error = e instanceof Error ? e : new Error(String(e));
logger.debug?.('Failed to forward automation-event', { error });
} }
}); });
lifecycleSubscribed = true; lifecycleSubscribed = true;
@@ -380,7 +381,8 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
} }
} }
} catch (e) { } catch (e) {
logger.debug?.('Failed to subscribe to adapter lifecycle events', e); const error = e instanceof Error ? e : new Error(String(e));
logger.debug?.('Failed to subscribe to adapter lifecycle events', { error });
} }
} }

View File

@@ -700,15 +700,15 @@ test('should create league', async ({ page }) => {
## Real E2E Testing Strategy (No Mocks) ## Real E2E Testing Strategy (No Mocks)
GridPilot requires two distinct E2E testing strategies due to the nature of its automation adapters: GridPilot focuses its real E2E testing strategy on browser-driven automation:
1. **Strategy A (Docker)**: Test `BrowserDevToolsAdapter` with Puppeteer against a fixture server 1. **Strategy A (Docker)**: Test `BrowserDevToolsAdapter` with Playwright or similar browser tooling against a fixture server
2. **Strategy B (Native macOS)**: Test `NutJsAutomationAdapter` on real hardware with display access 2. **Strategy B (Native macOS, legacy)**: Historical native OS-level automation on real hardware (now removed)
### Constraint: iRacing Terms of Service ### Constraint: iRacing Terms of Service
- **Production**: nut.js OS-level automation only (no Puppeteer/CDP for actual iRacing automation) - **Production**: Native OS-level automation only (no browser DevTools/CDP for actual iRacing automation)
- **Testing**: Puppeteer CAN be used to test `BrowserDevToolsAdapter` against static HTML fixtures - **Testing**: Playwright-driven automation CAN be used against static HTML fixtures
### Test Architecture Overview ### Test Architecture Overview
@@ -720,11 +720,7 @@ graph TB
HC --> BDA[BrowserDevToolsAdapter Tests] HC --> BDA[BrowserDevToolsAdapter Tests]
end end
subgraph Native E2E - macOS Runner %% Legacy native OS-level automation tests have been removed.
SCR[Screen Capture] --> TM[Template Matching Tests]
WF[Window Focus Tests] --> NJA[NutJsAutomationAdapter Tests]
KB[Keyboard/Mouse Tests] --> NJA
end
``` ```
--- ---
@@ -920,7 +916,7 @@ describe('E2E: BrowserDevToolsAdapter - Docker Environment', () => {
### Strategy B: Native macOS E2E Tests ### Strategy B: Native macOS E2E Tests
#### Purpose #### Purpose
Test OS-level screen automation using nut.js on real hardware. These tests CANNOT run in Docker because nut.js requires actual display access. Test OS-level screen automation on real hardware. These tests CANNOT run in Docker because native automation requires actual display access.
#### Requirements #### Requirements
- macOS CI runner with display access - macOS CI runner with display access
@@ -928,128 +924,17 @@ Test OS-level screen automation using nut.js on real hardware. These tests CANNO
- Accessibility permissions enabled - Accessibility permissions enabled
- Real Chrome/browser window visible - Real Chrome/browser window visible
#### BDD Scenarios for Native E2E #### BDD Scenarios for Native E2E (Legacy)
```gherkin > Historical note: previous native OS-level automation scenarios have been retired.
Feature: NutJsAutomationAdapter OS-Level Automation > Real-world coverage is now provided by Playwright-based workflows and fixture-backed
As the automation engine > automation; native OS-level adapters are no longer part of the supported stack.
I want to perform OS-level screen automation
So that I can interact with iRacing without browser DevTools
Background: #### Test Implementation Structure (Legacy)
Given I am running on macOS with display access
And accessibility permissions are granted
And screen recording permissions are granted
Scenario: Screen capture functionality Previous native OS-level adapter tests have been removed. The current
When I capture the full screen E2E coverage relies on Playwright-driven automation and fixture-backed
Then a valid image buffer should be returned flows as described in the Docker-based strategy above.
And the image dimensions should match screen resolution
Scenario: Window focus management
Given a Chrome window titled "iRacing" is open
When I focus the browser window
Then the Chrome window should become the active window
Scenario: Template matching detection
Given I have a template image for the "Create Race" button
And the iRacing hosted racing page is visible
When I search for the template on screen
Then the template should be found
And the location should have confidence > 0.8
Scenario: Mouse click at detected location
Given I have detected a button at coordinates 500,300
When I click at that location
Then the mouse should move to 500,300
And a left click should be performed
Scenario: Keyboard input simulation
Given a text field is focused
When I type "Test Session Name"
Then the text should be entered character by character
With appropriate delays between keystrokes
Scenario: Login state detection
Given the iRacing login page is displayed
When I detect the login state
Then the result should indicate logged out
And the login form indicator should be detected
Scenario: Safe automation - no checkout
Given I am on the Track Conditions step
When I execute step 18
Then no click should be performed on the checkout button
And the automation should report safety stop
```
#### Test Implementation Structure
```typescript
// tests/e2e/native/nutJsAdapter.e2e.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { NutJsAutomationAdapter } from '@infrastructure/adapters/automation/NutJsAutomationAdapter';
describe('E2E: NutJsAutomationAdapter - Native macOS', () => {
let adapter: NutJsAutomationAdapter;
beforeAll(async () => {
// Skip if not on macOS with display
if (process.platform !== 'darwin' || !process.env.DISPLAY_AVAILABLE) {
return;
}
adapter = new NutJsAutomationAdapter({
mouseSpeed: 500,
keyboardDelay: 25,
defaultTimeout: 10000,
});
await adapter.connect();
});
afterAll(async () => {
if (adapter?.isConnected()) {
await adapter.disconnect();
}
});
describe('Screen Capture', () => {
it('should capture full screen', async () => {
const result = await adapter.captureScreen();
expect(result.success).toBe(true);
expect(result.imageData).toBeDefined();
expect(result.dimensions.width).toBeGreaterThan(0);
});
it('should capture specific region', async () => {
const region = { x: 100, y: 100, width: 200, height: 200 };
const result = await adapter.captureScreen(region);
expect(result.success).toBe(true);
});
});
describe('Window Focus', () => {
it('should focus Chrome window', async () => {
const result = await adapter.focusBrowserWindow('Chrome');
// May fail if Chrome not open, which is acceptable
expect(result).toBeDefined();
});
});
describe('Template Matching', () => {
it('should find element by template', async () => {
const template = {
id: 'test-button',
imagePath: './resources/templates/test-button.png',
confidence: 0.8,
};
const location = await adapter.findElement(template);
// Template may not be on screen - test structure only
expect(location === null || location.confidence > 0).toBe(true);
});
});
});
```
--- ---
@@ -1218,10 +1103,8 @@ CHROME_WS_ENDPOINT=ws://localhost:9222
FIXTURE_BASE_URL=http://localhost:3456 FIXTURE_BASE_URL=http://localhost:3456
E2E_TIMEOUT=120000 E2E_TIMEOUT=120000
# Native E2E Configuration # Native E2E Configuration (legacy)
DISPLAY_AVAILABLE=true DISPLAY_AVAILABLE=true
NUT_JS_MOUSE_SPEED=500
NUT_JS_KEYBOARD_DELAY=25
``` ```
--- ---
@@ -1265,9 +1148,9 @@ For the iRacing hosted-session automation, confidence is provided by these concr
- Example: [`steps-07-09-cars-flow.e2e.test.ts`](tests/e2e/workflows/steps-07-09-cars-flow.e2e.test.ts:1) exercises cross-step cars flow, while [`full-hosted-session.workflow.e2e.test.ts`](tests/e2e/workflows/full-hosted-session.workflow.e2e.test.ts:1) runs a full 118 workflow via [`MockAutomationEngineAdapter`](packages/infrastructure/adapters/automation/engine/MockAutomationEngineAdapter.ts:1) and [`StartAutomationSessionUseCase`](packages/application/use-cases/StartAutomationSessionUseCase.ts:1), asserting final `SessionState` and step position. - Example: [`steps-07-09-cars-flow.e2e.test.ts`](tests/e2e/workflows/steps-07-09-cars-flow.e2e.test.ts:1) exercises cross-step cars flow, while [`full-hosted-session.workflow.e2e.test.ts`](tests/e2e/workflows/full-hosted-session.workflow.e2e.test.ts:1) runs a full 118 workflow via [`MockAutomationEngineAdapter`](packages/infrastructure/adapters/automation/engine/MockAutomationEngineAdapter.ts:1) and [`StartAutomationSessionUseCase`](packages/application/use-cases/StartAutomationSessionUseCase.ts:1), asserting final `SessionState` and step position.
- Additional workflow scenarios cover mid-flow failure using [`MockBrowserAutomationAdapter`](packages/infrastructure/adapters/automation/engine/MockBrowserAutomationAdapter.ts:1), ensuring failure states and diagnostics are surfaced without emitting false confirmations. - Additional workflow scenarios cover mid-flow failure using [`MockBrowserAutomationAdapter`](packages/infrastructure/adapters/automation/engine/MockBrowserAutomationAdapter.ts:1), ensuring failure states and diagnostics are surfaced without emitting false confirmations.
- **Opt-in real-world automation smoke tests (true iRacing / NutJs)** - **Opt-in real-world automation smoke tests (legacy)**
- The legacy real iRacing automation suite [`automation.e2e.test.ts`](tests/e2e/automation.e2e.test.ts:1) is treated as a smoke-only, opt-in layer. - The legacy real iRacing automation suite [`automation.e2e.test.ts`](tests/e2e/automation.e2e.test.ts:1) is now a skipped, documentation-only layer.
- It is gated by `RUN_REAL_AUTOMATION_SMOKE=1` and should not run in normal CI; it exists to validate that NutJs / template-based automation still matches the real UI for a small number of manual smoke runs on a prepared macOS environment. - It is gated by `RUN_REAL_AUTOMATION_SMOKE=1` but no longer performs native OS-level automation; real confidence comes from Playwright-based fixture and workflow suites.
#### Confidence expectations #### Confidence expectations
@@ -1277,7 +1160,7 @@ For the iRacing hosted-session automation, confidence is provided by these concr
- All **step E2E tests** under [`tests/e2e/steps`](tests/e2e/steps:1). - All **step E2E tests** under [`tests/e2e/steps`](tests/e2e/steps:1).
- All **workflow E2E tests** under [`tests/e2e/workflows`](tests/e2e/workflows:1). - All **workflow E2E tests** under [`tests/e2e/workflows`](tests/e2e/workflows:1).
- The **real-world smoke suite** in [`tests/e2e/automation.e2e.test.ts`](tests/e2e/automation.e2e.test.ts:1) is an additional, manual confidence layer and should be run only on configured machines when validating large changes to NutJs automation, template packs, or iRacing UI assumptions. - The **real-world smoke suite** in [`tests/e2e/automation.e2e.test.ts`](tests/e2e/automation.e2e.test.ts:1) remains as historical documentation and should not be relied upon for validating changes; instead, update and extend the Playwright-based E2E and workflow suites.
- When adding new behavior: - When adding new behavior:
- Prefer **unit tests** for domain/application changes. - Prefer **unit tests** for domain/application changes.

View File

@@ -51,7 +51,7 @@ export class OverlaySyncService implements IOverlaySyncPort {
const cleanup = () => { const cleanup = () => {
try { try {
this.lifecycleEmitter.offLifecycle(cb) this.lifecycleEmitter.offLifecycle(cb)
} catch (e) { } catch {
// ignore // ignore
} }
} }
@@ -59,36 +59,45 @@ export class OverlaySyncService implements IOverlaySyncPort {
let resolveAck: (ack: ActionAck) => void = () => {} let resolveAck: (ack: ActionAck) => void = () => {}
const promise = new Promise<ActionAck>((resolve) => { const promise = new Promise<ActionAck>((resolve) => {
resolveAck = resolve resolveAck = resolve
// subscribe
try { try {
this.lifecycleEmitter.onLifecycle(cb) this.lifecycleEmitter.onLifecycle(cb)
} catch (e) { } catch (e) {
this.logger?.error?.('OverlaySyncService: failed to subscribe to lifecycleEmitter', e) const error = e instanceof Error ? e : new Error(String(e))
this.logger?.error?.('OverlaySyncService: failed to subscribe to lifecycleEmitter', error, {
actionId: action.id,
})
} }
}) })
// publish overlay request (best-effort)
try { try {
this.publisher.publish({ void this.publisher.publish({
type: 'modal-opened', type: 'modal-opened',
timestamp: Date.now(), timestamp: Date.now(),
payload: { actionId: action.id, label: action.label }, payload: { actionId: action.id, label: action.label },
actionId: action.id, actionId: action.id,
} as AutomationEvent) } as AutomationEvent)
} catch (e) { } catch (e) {
this.logger?.warn?.('OverlaySyncService: publisher.publish failed', e) const error = e instanceof Error ? e : new Error(String(e))
this.logger?.warn?.('OverlaySyncService: publisher.publish failed', {
actionId: action.id,
error,
})
} }
// timeout handling
const timeoutPromise = new Promise<ActionAck>((res) => { const timeoutPromise = new Promise<ActionAck>((res) => {
setTimeout(() => { setTimeout(() => {
if (!settled) { if (!settled) {
settled = true settled = true
cleanup() cleanup()
this.logger?.info?.('OverlaySyncService: timeout waiting for confirmation', { actionId: action.id, timeoutMs }) this.logger?.info?.('OverlaySyncService: timeout waiting for confirmation', {
// log recent events truncated actionId: action.id,
timeoutMs,
})
const lastEvents = seenEvents.slice(-10) const lastEvents = seenEvents.slice(-10)
this.logger?.debug?.('OverlaySyncService: recent lifecycle events', lastEvents) this.logger?.debug?.('OverlaySyncService: recent lifecycle events', {
actionId: action.id,
events: lastEvents,
})
res({ id: action.id, status: 'tentative', reason: 'timeout' }) res({ id: action.id, status: 'tentative', reason: 'timeout' })
} }
}, timeoutMs) }, timeoutMs)
@@ -98,7 +107,6 @@ export class OverlaySyncService implements IOverlaySyncPort {
} }
async cancelAction(actionId: string): Promise<void> { async cancelAction(actionId: string): Promise<void> {
// best-effort: publish cancellation
try { try {
await this.publisher.publish({ await this.publisher.publish({
type: 'panel-missing', type: 'panel-missing',
@@ -106,7 +114,11 @@ export class OverlaySyncService implements IOverlaySyncPort {
actionId, actionId,
} as AutomationEvent) } as AutomationEvent)
} catch (e) { } catch (e) {
this.logger?.warn?.('OverlaySyncService: cancelAction publish failed', e) const error = e instanceof Error ? e : new Error(String(e))
this.logger?.warn?.('OverlaySyncService: cancelAction publish failed', {
actionId,
error,
})
} }
} }
} }

View File

@@ -16,22 +16,15 @@ export class VerifyAuthenticatedPageUseCase {
const result = await this.authService.verifyPageAuthentication(); const result = await this.authService.verifyPageAuthentication();
if (result.isErr()) { if (result.isErr()) {
return Result.err(result.error); const error = result.error ?? new Error('Page verification failed');
return Result.err<BrowserAuthenticationState>(error);
} }
const browserState = result.unwrap(); const browserState = result.unwrap();
return Result.ok<BrowserAuthenticationState>(browserState);
// 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) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
return Result.err(new Error(`Page verification failed: ${message}`)); return Result.err<BrowserAuthenticationState>(new Error(`Page verification failed: ${message}`));
} }
} }
} }

View File

@@ -1,24 +1,28 @@
export interface HostedSessionConfig { export interface HostedSessionConfig {
sessionName: string; sessionName: string;
serverName: string;
password: string;
adminPassword: string;
maxDrivers: number;
trackId: string; trackId: string;
carIds: string[]; carIds: string[];
// Optional fields for extended configuration.
serverName?: string;
password?: string;
adminPassword?: string;
maxDrivers?: number;
/** Search term for car selection (alternative to carIds) */ /** Search term for car selection (alternative to carIds) */
carSearch?: string; carSearch?: string;
/** Search term for track selection (alternative to trackId) */ /** Search term for track selection (alternative to trackId) */
trackSearch?: string; trackSearch?: string;
weatherType: 'static' | 'dynamic';
timeOfDay: 'morning' | 'afternoon' | 'evening' | 'night'; weatherType?: 'static' | 'dynamic';
sessionDuration: number; timeOfDay?: 'morning' | 'afternoon' | 'evening' | 'night';
practiceLength: number; sessionDuration?: number;
qualifyingLength: number; practiceLength?: number;
warmupLength: number; qualifyingLength?: number;
raceLength: number; warmupLength?: number;
startType: 'standing' | 'rolling'; raceLength?: number;
restarts: 'single-file' | 'double-file'; startType?: 'standing' | 'rolling';
damageModel: 'off' | 'limited' | 'realistic'; restarts?: 'single-file' | 'double-file';
trackState: 'auto' | 'clean' | 'moderately-low' | 'moderately-high' | 'optimum'; damageModel?: 'off' | 'limited' | 'realistic';
trackState?: 'auto' | 'clean' | 'moderately-low' | 'moderately-high' | 'optimum';
} }

View File

@@ -9,6 +9,8 @@ interface Page {
} }
interface Locator { interface Locator {
first(): Locator;
locator(selector: string): Locator;
getAttribute(name: string): Promise<string | null>; getAttribute(name: string): Promise<string | null>;
innerHTML(): Promise<string>; innerHTML(): Promise<string>;
textContent(): Promise<string | null>; textContent(): Promise<string | null>;

View File

@@ -277,14 +277,13 @@ export class SessionCookieStore {
validateCookieConfiguration(targetUrl: string): Result<Cookie[]> { validateCookieConfiguration(targetUrl: string): Result<Cookie[]> {
try { try {
if (!this.cachedState || this.cachedState.cookies.length === 0) { if (!this.cachedState || this.cachedState.cookies.length === 0) {
return Result.err('No cookies found in session store'); return Result.err<Cookie[]>(new Error('No cookies found in session store'));
} }
const result = this.validateCookiesForUrl(this.cachedState.cookies, targetUrl, true); return this.validateCookiesForUrl(this.cachedState.cookies, targetUrl, true);
return result;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
return Result.err(`Cookie validation failed: ${message}`); return Result.err<Cookie[]>(new Error(`Cookie validation failed: ${message}`));
} }
} }
@@ -299,62 +298,57 @@ export class SessionCookieStore {
requireAuthCookies = false requireAuthCookies = false
): Result<Cookie[]> { ): Result<Cookie[]> {
try { try {
// Validate each cookie's domain/path
const validatedCookies: Cookie[] = []; const validatedCookies: Cookie[] = [];
let firstValidationError: string | null = null; let firstValidationError: Error | null = null;
for (const cookie of cookies) { for (const cookie of cookies) {
try { try {
new CookieConfiguration(cookie, targetUrl); new CookieConfiguration(cookie, targetUrl);
validatedCookies.push(cookie); validatedCookies.push(cookie);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const err = error instanceof Error ? error : new Error(String(error));
// Capture first validation error to return if all cookies fail
if (!firstValidationError) { if (!firstValidationError) {
firstValidationError = message; firstValidationError = err;
} }
this.logger?.warn('Cookie validation failed', { this.logger?.warn('Cookie validation failed', {
name: cookie.name, name: cookie.name,
error: message, error: err.message,
}); });
// Skip invalid cookie, continue with others
} }
} }
if (validatedCookies.length === 0) { if (validatedCookies.length === 0) {
// Return the specific validation error from the first failed cookie return Result.err<Cookie[]>(
return Result.err(firstValidationError || 'No valid cookies found for target URL'); firstValidationError ?? new Error('No valid cookies found for target URL')
);
} }
// Check required cookies only if requested (for authentication validation)
if (requireAuthCookies) { if (requireAuthCookies) {
const cookieNames = validatedCookies.map((c) => c.name.toLowerCase()); const cookieNames = validatedCookies.map((c) => c.name.toLowerCase());
// Check for irsso_members
const hasIrssoMembers = cookieNames.some((name) => const hasIrssoMembers = cookieNames.some((name) =>
name.includes('irsso_members') || name.includes('irsso') name.includes('irsso_members') || name.includes('irsso')
); );
// Check for authtoken_members
const hasAuthtokenMembers = cookieNames.some((name) => const hasAuthtokenMembers = cookieNames.some((name) =>
name.includes('authtoken_members') || name.includes('authtoken') name.includes('authtoken_members') || name.includes('authtoken')
); );
if (!hasIrssoMembers) { if (!hasIrssoMembers) {
return Result.err('Required cookie missing: irsso_members'); return Result.err<Cookie[]>(new Error('Required cookie missing: irsso_members'));
} }
if (!hasAuthtokenMembers) { if (!hasAuthtokenMembers) {
return Result.err('Required cookie missing: authtoken_members'); return Result.err<Cookie[]>(new Error('Required cookie missing: authtoken_members'));
} }
} }
return Result.ok(validatedCookies); return Result.ok<Cookie[]>(validatedCookies);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const err = error instanceof Error ? error : new Error(String(error));
return Result.err(`Cookie validation failed: ${message}`); return Result.err<Cookie[]>(new Error(`Cookie validation failed: ${err.message}`));
} }
} }

View File

@@ -1,7 +1,6 @@
import type { Browser, Page, BrowserContext } from 'playwright'; import type { Browser, Page, BrowserContext } from 'playwright';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { extractDom, ExportedElement } from '../../../../scripts/dom-export/domExtractor';
import { StepId } from '../../../../domain/value-objects/StepId'; import { StepId } from '../../../../domain/value-objects/StepId';
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState'; import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from '../../../../domain/value-objects/BrowserAuthenticationState'; import { BrowserAuthenticationState } from '../../../../domain/value-objects/BrowserAuthenticationState';
@@ -775,6 +774,30 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
} }
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> { async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
const stepNumber = stepId.value;
if (!this.isRealMode() && this.config.baseUrl) {
if (stepNumber >= 2 && stepNumber <= this.totalSteps) {
try {
const fixture = getFixtureForStep(stepNumber);
if (fixture) {
const base = this.config.baseUrl.replace(/\/$/, '');
const url = `${base}/${fixture}`;
this.log('debug', 'Mock mode: navigating to fixture for step', {
step: stepNumber,
url,
});
await this.navigator.navigateToPage(url);
}
} catch (error) {
this.log('debug', 'Mock mode fixture navigation failed (non-fatal)', {
step: stepNumber,
error: String(error),
});
}
}
}
return this.stepOrchestrator.executeStep(stepId, config); return this.stepOrchestrator.executeStep(stepId, config);
} }
@@ -888,36 +911,34 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
* *
* Error dumps are always kept and not subject to cleanup. * Error dumps are always kept and not subject to cleanup.
*/ */
private async saveDebugInfo(stepName: string, error: Error): Promise<{ screenshotPath?: string; htmlPath?: string; domPath?: string }> { private async saveDebugInfo(stepName: string, error: Error): Promise<{ screenshotPath?: string; htmlPath?: string }> {
if (!this.page) return {}; if (!this.page) return {};
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const baseName = `debug-error-${stepName}-${timestamp}`; const baseName = `debug-error-${stepName}-${timestamp}`;
const debugDir = path.join(process.cwd(), 'debug-screenshots'); const debugDir = path.join(process.cwd(), 'debug-screenshots');
const result: { screenshotPath?: string; htmlPath?: string; domPath?: string } = {}; const result: { screenshotPath?: string; htmlPath?: string } = {};
try { try {
await fs.promises.mkdir(debugDir, { recursive: true }); await fs.promises.mkdir(debugDir, { recursive: true });
// Save screenshot
const screenshotPath = path.join(debugDir, `${baseName}.png`); const screenshotPath = path.join(debugDir, `${baseName}.png`);
await this.page.screenshot({ path: screenshotPath, fullPage: true }); await this.page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshotPath = screenshotPath; result.screenshotPath = screenshotPath;
this.log('error', `Error debug screenshot saved: ${screenshotPath}`, { path: screenshotPath, error: error.message }); this.log('error', `Error debug screenshot saved: ${screenshotPath}`, {
path: screenshotPath,
error: error.message,
});
// Save HTML (cleaned to remove noise)
const htmlPath = path.join(debugDir, `${baseName}.html`); const htmlPath = path.join(debugDir, `${baseName}.html`);
const html = await this.page.evaluate(() => { const html = await this.page.evaluate(() => {
// Clone the document
const root = document.documentElement.cloneNode(true) as HTMLElement; const root = document.documentElement.cloneNode(true) as HTMLElement;
// Remove noise elements
['script', 'noscript', 'meta', 'base', 'style', 'link', 'iframe', ['script', 'noscript', 'meta', 'base', 'style', 'link', 'iframe',
'picture', 'source', 'svg', 'path', 'img', 'canvas', 'video', 'audio'] 'picture', 'source', 'svg', 'path', 'img', 'canvas', 'video', 'audio']
.forEach(sel => root.querySelectorAll(sel).forEach(n => n.remove())); .forEach((sel) => root.querySelectorAll(sel).forEach((n) => n.remove()));
// Remove empty non-interactive elements root.querySelectorAll('*').forEach((n) => {
root.querySelectorAll('*').forEach(n => {
const text = (n.textContent || '').trim(); const text = (n.textContent || '').trim();
const interactive = n.matches('a,button,input,select,textarea,option,label'); const interactive = n.matches('a,button,input,select,textarea,option,label');
if (!interactive && text === '' && n.children.length === 0) { if (!interactive && text === '' && n.children.length === 0) {
@@ -930,22 +951,6 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
await fs.promises.writeFile(htmlPath, html); await fs.promises.writeFile(htmlPath, html);
result.htmlPath = htmlPath; result.htmlPath = htmlPath;
this.log('error', `Error debug HTML saved: ${htmlPath}`, { path: htmlPath }); this.log('error', `Error debug HTML saved: ${htmlPath}`, { path: htmlPath });
// Save structural DOM export alongside HTML
try {
await this.page.evaluate(() => {
(window as any).__name = (window as any).__name || ((fn: any) => fn);
});
const items = (await this.page.evaluate(
extractDom as () => ExportedElement[]
)) as unknown as ExportedElement[];
const domPath = path.join(debugDir, `${baseName}.dom.json`);
await fs.promises.writeFile(domPath, JSON.stringify(items, null, 2), 'utf8');
result.domPath = domPath;
this.log('error', `Error debug DOM saved: ${domPath}`, { path: domPath });
} catch (domErr) {
this.log('warn', 'Failed to save error debug DOM', { error: String(domErr) });
}
} catch (e) { } catch (e) {
this.log('warn', 'Failed to save error debug info', { error: String(e) }); this.log('warn', 'Failed to save error debug info', { error: String(e) });
} }
@@ -960,39 +965,36 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
* Files are named with "before-step-N" prefix and old snapshots are cleaned up * Files are named with "before-step-N" prefix and old snapshots are cleaned up
* to avoid disk bloat (keeps only last MAX_BEFORE_SNAPSHOTS). * to avoid disk bloat (keeps only last MAX_BEFORE_SNAPSHOTS).
*/ */
private async saveProactiveDebugInfo(step: number): Promise<{ screenshotPath?: string; htmlPath?: string; domPath?: string }> { private async saveProactiveDebugInfo(step: number): Promise<{ screenshotPath?: string; htmlPath?: string }> {
if (!this.page) return {}; if (!this.page) return {};
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const baseName = `debug-before-step-${step}-${timestamp}`; const baseName = `debug-before-step-${step}-${timestamp}`;
const debugDir = path.join(process.cwd(), 'debug-screenshots'); const debugDir = path.join(process.cwd(), 'debug-screenshots');
const result: { screenshotPath?: string; htmlPath?: string; domPath?: string } = {}; const result: { screenshotPath?: string; htmlPath?: string } = {};
try { try {
await fs.promises.mkdir(debugDir, { recursive: true }); await fs.promises.mkdir(debugDir, { recursive: true });
// Clean up old "before" snapshots first
await this.cleanupOldBeforeSnapshots(debugDir); await this.cleanupOldBeforeSnapshots(debugDir);
// Save screenshot
const screenshotPath = path.join(debugDir, `${baseName}.png`); const screenshotPath = path.join(debugDir, `${baseName}.png`);
await this.page.screenshot({ path: screenshotPath, fullPage: true }); await this.page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshotPath = screenshotPath; result.screenshotPath = screenshotPath;
this.log('info', `Pre-step screenshot saved: ${screenshotPath}`, { path: screenshotPath, step }); this.log('info', `Pre-step screenshot saved: ${screenshotPath}`, {
path: screenshotPath,
step,
});
// Save HTML (cleaned to remove noise)
const htmlPath = path.join(debugDir, `${baseName}.html`); const htmlPath = path.join(debugDir, `${baseName}.html`);
const html = await this.page.evaluate(() => { const html = await this.page.evaluate(() => {
// Clone the document
const root = document.documentElement.cloneNode(true) as HTMLElement; const root = document.documentElement.cloneNode(true) as HTMLElement;
// Remove noise elements
['script', 'noscript', 'meta', 'base', 'style', 'link', 'iframe', ['script', 'noscript', 'meta', 'base', 'style', 'link', 'iframe',
'picture', 'source', 'svg', 'path', 'img', 'canvas', 'video', 'audio'] 'picture', 'source', 'svg', 'path', 'img', 'canvas', 'video', 'audio']
.forEach(sel => root.querySelectorAll(sel).forEach(n => n.remove())); .forEach((sel) => root.querySelectorAll(sel).forEach((n) => n.remove()));
// Remove empty non-interactive elements root.querySelectorAll('*').forEach((n) => {
root.querySelectorAll('*').forEach(n => {
const text = (n.textContent || '').trim(); const text = (n.textContent || '').trim();
const interactive = n.matches('a,button,input,select,textarea,option,label'); const interactive = n.matches('a,button,input,select,textarea,option,label');
if (!interactive && text === '' && n.children.length === 0) { if (!interactive && text === '' && n.children.length === 0) {
@@ -1005,24 +1007,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
await fs.promises.writeFile(htmlPath, html); await fs.promises.writeFile(htmlPath, html);
result.htmlPath = htmlPath; result.htmlPath = htmlPath;
this.log('info', `Pre-step HTML saved: ${htmlPath}`, { path: htmlPath, step }); this.log('info', `Pre-step HTML saved: ${htmlPath}`, { path: htmlPath, step });
// Save structural DOM export alongside HTML
try {
await this.page.evaluate(() => {
(window as any).__name = (window as any).__name || ((fn: any) => fn);
});
const items = (await this.page.evaluate(
extractDom as () => ExportedElement[]
)) as unknown as ExportedElement[];
const domPath = path.join(debugDir, `${baseName}.dom.json`);
await fs.promises.writeFile(domPath, JSON.stringify(items, null, 2), 'utf8');
result.domPath = domPath;
this.log('info', `Pre-step DOM saved: ${domPath}`, { path: domPath, step });
} catch (domErr) {
this.log('warn', 'Failed to save proactive debug DOM', { error: String(domErr), step });
}
} catch (e) { } catch (e) {
// Don't fail step execution if debug save fails
this.log('warn', 'Failed to save proactive debug info', { error: String(e), step }); this.log('warn', 'Failed to save proactive debug info', { error: String(e), step });
} }

View File

@@ -307,13 +307,8 @@ export class WizardStepOrchestrator {
break; break;
case 2: case 2:
if (!this.isRealMode() && this.config.baseUrl) { if (!this.isRealMode()) {
const fixture = getFixtureForStep(2); break;
if (fixture) {
const base = this.config.baseUrl.replace(/\/$/, '');
await this.navigator.navigateToPage(`${base}/${fixture}`);
break;
}
} }
await this.clickAction('create'); await this.clickAction('create');
break; break;
@@ -351,11 +346,12 @@ export class WizardStepOrchestrator {
'Race Information panel not found with fallback selector, dumping #create-race-wizard innerHTML', 'Race Information panel not found with fallback selector, dumping #create-race-wizard innerHTML',
{ selector: raceInfoFallback }, { selector: raceInfoFallback },
); );
const inner = await this.page!.evaluate( const inner = await this.page!.evaluate(() => {
() => const doc = (globalThis as any).document as any;
document.querySelector('#create-race-wizard')?.innerHTML || return (
'', doc?.querySelector('#create-race-wizard')?.innerHTML || ''
); );
});
this.log( this.log(
'debug', 'debug',
'create-race-wizard innerHTML (truncated)', 'create-race-wizard innerHTML (truncated)',
@@ -412,14 +408,57 @@ export class WizardStepOrchestrator {
if (this.isRealMode()) { if (this.isRealMode()) {
await this.waitForWizardStep('admins'); await this.waitForWizardStep('admins');
await this.checkWizardDismissed(step); await this.checkWizardDismissed(step);
} else {
const adminSearch =
(config.adminSearch ?? config.admin) as string | undefined;
if (adminSearch) {
await this.fillField('adminSearch', String(adminSearch));
}
} }
await this.clickNextButton('Time Limit'); await this.clickNextButton('Time Limit');
break; break;
case 6: case 6:
if (this.isRealMode()) { if (this.isRealMode()) {
await this.waitForWizardStep('admins'); await this.waitForWizardStep('admins');
await this.checkWizardDismissed(step); await this.checkWizardDismissed(step);
} else {
const adminSearch =
(config.adminSearch ?? config.admin) as string | undefined;
if (adminSearch) {
await this.fillField('adminSearch', String(adminSearch));
const page = this.page;
if (page) {
await page.evaluate((term) => {
const doc = (globalThis as any).document as any;
if (!doc) {
return;
}
const root =
(doc.querySelector('#set-admins') as any) ?? doc.body;
if (!root) {
return;
}
const rows = Array.from(
(root as any).querySelectorAll(
'tbody[data-testid="admin-display-name-list"] tr',
),
) as any[];
if (rows.length === 0) {
return;
}
const needle = String(term).toLowerCase();
for (const r of rows) {
const text = String((r as any).textContent || '').toLowerCase();
if (text.includes(needle)) {
(r as any).setAttribute('data-selected-admin', 'true');
return;
}
}
(rows[0] as any).setAttribute('data-selected-admin', 'true');
}, String(adminSearch));
}
}
} }
await this.clickNextButton('Time Limit'); await this.clickNextButton('Time Limit');
break; break;
@@ -553,11 +592,11 @@ export class WizardStepOrchestrator {
case 9: case 9:
this.log('info', 'Step 9: Validating we are still on Cars page'); this.log('info', 'Step 9: Validating we are still on Cars page');
if (this.isRealMode()) { if (this.isRealMode()) {
const actualPage = await this.detectCurrentWizardPage(); const actualPage = await this.detectCurrentWizardPage();
const skipOffset = this.synchronizeStepCounter(step, actualPage); const skipOffset = this.synchronizeStepCounter(step, actualPage);
if (skipOffset > 0) { if (skipOffset > 0) {
this.log('info', `Step ${step} was auto-skipped by wizard`, { this.log('info', `Step ${step} was auto-skipped by wizard`, {
actualPage, actualPage,
@@ -565,7 +604,7 @@ export class WizardStepOrchestrator {
}); });
return { success: true }; return { success: true };
} }
const wizardFooter = await this.page! const wizardFooter = await this.page!
.locator('.wizard-footer') .locator('.wizard-footer')
.innerText() .innerText()
@@ -573,21 +612,21 @@ export class WizardStepOrchestrator {
this.log('info', 'Step 9: Current wizard footer', { this.log('info', 'Step 9: Current wizard footer', {
footer: wizardFooter, footer: wizardFooter,
}); });
const onTrackPage = const onTrackPage =
wizardFooter.includes('Track Options') || wizardFooter.includes('Track Options') ||
(await this.page! (await this.page!
.locator(IRACING_SELECTORS.wizard.stepContainers.track) .locator(IRACING_SELECTORS.wizard.stepContainers.track)
.isVisible() .isVisible()
.catch(() => false)); .catch(() => false));
if (onTrackPage) { if (onTrackPage) {
const errorMsg = `FATAL: Step 9 attempted on Track page (Step 11) - navigation bug detected. Wizard footer: "${wizardFooter}"`; const errorMsg = `FATAL: Step 9 attempted on Track page (Step 11) - navigation bug detected. Wizard footer: "${wizardFooter}"`;
this.log('error', errorMsg); this.log('error', errorMsg);
throw new Error(errorMsg); throw new Error(errorMsg);
} }
} }
const validation = await this.validatePageState({ const validation = await this.validatePageState({
expectedStep: 'cars', expectedStep: 'cars',
requiredSelectors: this.isRealMode() requiredSelectors: this.isRealMode()
@@ -595,7 +634,7 @@ export class WizardStepOrchestrator {
: ['#set-cars, .wizard-step[id*="cars"], .cars-panel'], : ['#set-cars, .wizard-step[id*="cars"], .cars-panel'],
forbiddenSelectors: ['#set-track'], forbiddenSelectors: ['#set-track'],
}); });
if (validation.isErr()) { if (validation.isErr()) {
const errorMsg = `Step 9 validation error: ${ const errorMsg = `Step 9 validation error: ${
validation.error?.message ?? 'unknown error' validation.error?.message ?? 'unknown error'
@@ -603,7 +642,7 @@ export class WizardStepOrchestrator {
this.log('error', errorMsg); this.log('error', errorMsg);
throw new Error(errorMsg); throw new Error(errorMsg);
} }
const validationResult = validation.unwrap(); const validationResult = validation.unwrap();
this.log('info', 'Step 9 validation result', { this.log('info', 'Step 9 validation result', {
isValid: validationResult.isValid, isValid: validationResult.isValid,
@@ -611,7 +650,7 @@ export class WizardStepOrchestrator {
missingSelectors: validationResult.missingSelectors, missingSelectors: validationResult.missingSelectors,
unexpectedSelectors: validationResult.unexpectedSelectors, unexpectedSelectors: validationResult.unexpectedSelectors,
}); });
if (!validationResult.isValid) { if (!validationResult.isValid) {
const errorMsg = `Step 9 FAILED validation: ${ const errorMsg = `Step 9 FAILED validation: ${
validationResult.message validationResult.message
@@ -626,14 +665,16 @@ export class WizardStepOrchestrator {
}); });
throw new Error(errorMsg); throw new Error(errorMsg);
} }
this.log('info', 'Step 9 validation passed - confirmed on Cars page'); this.log('info', 'Step 9 validation passed - confirmed on Cars page');
const carIds = config.carIds as string[] | undefined;
const carSearchTerm =
(config.carSearch as string | undefined) ||
(config.car as string | undefined) ||
carIds?.[0];
if (this.isRealMode()) { if (this.isRealMode()) {
const carIds = config.carIds as string[] | undefined;
const carSearchTerm =
config.carSearch || config.car || carIds?.[0];
if (carSearchTerm) { if (carSearchTerm) {
await this.clickAddCarButton(); await this.clickAddCarButton();
await this.waitForAddCarModal(); await this.waitForAddCarModal();
@@ -647,11 +688,31 @@ export class WizardStepOrchestrator {
await this.clickNextButton('Car Classes'); await this.clickNextButton('Car Classes');
} else { } else {
if (config.carSearch) { if (carSearchTerm) {
await this.fillField('carSearch', String(config.carSearch)); const page = this.page;
await this.clickAction('confirm'); if (page) {
await this.clickAddCarButton();
await this.waitForAddCarModal();
await this.fillField('carSearch', String(carSearchTerm));
await page.waitForTimeout(200);
try {
await this.selectFirstSearchResult();
} catch (e) {
this.log('debug', 'Step 9 mock mode: selectFirstSearchResult failed (non-fatal)', {
error: String(e),
});
}
this.log('info', 'Step 9 mock mode: selected car from JSON-backed list', {
carSearch: String(carSearchTerm),
});
}
} else {
this.log(
'debug',
'Step 9 mock mode: no carSearch provided, skipping car addition',
);
} }
await this.clickNextButton('Car Classes');
} }
break; break;
@@ -805,13 +866,23 @@ export class WizardStepOrchestrator {
await this.waitForWizardStep('timeOfDay'); await this.waitForWizardStep('timeOfDay');
await this.checkWizardDismissed(step); await this.checkWizardDismissed(step);
}
if (config.trackConfig) { if (config.timeOfDay !== undefined) {
await this.selectDropdown('trackConfig', String(config.trackConfig)); await this.setSlider('timeOfDay', Number(config.timeOfDay));
} }
await this.clickNextButton('Time of Day'); if (config.trackConfig) {
if (this.isRealMode()) { await this.selectDropdown('trackConfig', String(config.trackConfig));
}
await this.clickNextButton('Time of Day');
await this.waitForWizardStep('timeOfDay'); await this.waitForWizardStep('timeOfDay');
} else {
if (config.timeOfDay !== undefined) {
await this.setSlider('timeOfDay', Number(config.timeOfDay));
}
if (config.trackConfig) {
await this.selectDropdown('trackConfig', String(config.trackConfig));
}
} }
break; break;
@@ -863,11 +934,12 @@ export class WizardStepOrchestrator {
'Weather panel not found with fallback selector, dumping #create-race-wizard innerHTML', 'Weather panel not found with fallback selector, dumping #create-race-wizard innerHTML',
{ selector: weatherFallbackSelector }, { selector: weatherFallbackSelector },
); );
const inner = await this.page!.evaluate( const inner = await this.page!.evaluate(() => {
() => const doc = (globalThis as any).document as any;
document.querySelector('#create-race-wizard')?.innerHTML || return (
'', doc?.querySelector('#create-race-wizard')?.innerHTML || ''
); );
});
this.log( this.log(
'debug', 'debug',
'create-race-wizard innerHTML (truncated)', 'create-race-wizard innerHTML (truncated)',
@@ -889,14 +961,32 @@ export class WizardStepOrchestrator {
}); });
} }
await this.checkWizardDismissed(step); await this.checkWizardDismissed(step);
if (config.timeOfDay !== undefined) {
await this.setSlider('timeOfDay', Number(config.timeOfDay));
}
} else {
if (config.weatherType) {
await this.selectDropdown('weatherType', String(config.weatherType));
}
if (config.temperature !== undefined && this.page) {
const tempSelector = IRACING_SELECTORS.steps.temperature;
const tempExists =
(await this.page
.locator(tempSelector)
.first()
.count()
.catch(() => 0)) > 0;
if (tempExists) {
await this.setSlider('temperature', Number(config.temperature));
}
}
} }
if (config.timeOfDay !== undefined) {
await this.setSlider('timeOfDay', Number(config.timeOfDay));
}
if (this.isRealMode()) { if (this.isRealMode()) {
await this.dismissDatetimePickers(); await this.dismissDatetimePickers();
await this.clickNextButton('Weather');
} }
await this.clickNextButton('Weather');
break; break;
case 16: case 16:
@@ -1000,6 +1090,10 @@ export class WizardStepOrchestrator {
} else { } else {
const valueStr = String(config.trackState); const valueStr = String(config.trackState);
await this.page!.evaluate((trackStateValue) => { await this.page!.evaluate((trackStateValue) => {
const doc = (globalThis as any).document as any;
if (!doc) {
return;
}
const map: Record<string, number> = { const map: Record<string, number> = {
'very-low': 10, 'very-low': 10,
low: 25, low: 25,
@@ -1011,24 +1105,29 @@ export class WizardStepOrchestrator {
}; };
const numeric = map[trackStateValue] ?? null; const numeric = map[trackStateValue] ?? null;
const inputs = Array.from( const inputs = Array.from(
document.querySelectorAll<HTMLInputElement>( doc.querySelectorAll(
'input[id*="starting-track-state"], input[id*="track-state"], input[data-value]', 'input[id*="starting-track-state"], input[id*="track-state"], input[data-value]',
), ),
); ) as any[];
if (numeric !== null && inputs.length > 0) { if (numeric !== null && inputs.length > 0) {
for (const inp of inputs) { for (const inp of inputs) {
try { try {
inp.value = String(numeric); (inp as any).value = String(numeric);
(inp as any).dataset = (inp as any).dataset || {}; const ds =
(inp as any).dataset.value = String(numeric); (inp as any).dataset || ((inp as any).dataset = {});
inp.setAttribute('data-value', String(numeric)); ds.value = String(numeric);
inp.dispatchEvent( (inp as any).setAttribute?.(
new Event('input', { bubbles: true }), 'data-value',
String(numeric),
); );
inp.dispatchEvent( const Ev = (globalThis as any).Event;
new Event('change', { bubbles: true }), (inp as any).dispatchEvent?.(
new Ev('input', { bubbles: true }),
); );
} catch (e) { (inp as any).dispatchEvent?.(
new Ev('change', { bubbles: true }),
);
} catch {
} }
} }
} }

View File

@@ -280,6 +280,30 @@ export class IRacingDomInteractor {
const page = this.getPage(); const page = this.getPage();
if (!this.isRealMode()) { if (!this.isRealMode()) {
const timeout = this.config.timeout;
try {
const footerButtons = page.locator('.wizard-footer a.btn, .wizard-footer button');
const count = await footerButtons.count().catch(() => 0);
if (count > 0) {
const targetText = nextStepName.toLowerCase();
for (let i = 0; i < count; i++) {
const button = footerButtons.nth(i);
const text = (await button.innerText().catch(() => '')).trim().toLowerCase();
if (text && text.includes(targetText)) {
await button.click({ timeout, force: true });
this.log('info', 'Clicked mock next button via footer text match', {
nextStepName,
text,
});
return;
}
}
}
} catch {
}
await this.clickAction('next'); await this.clickAction('next');
return; return;
} }
@@ -346,7 +370,13 @@ export class IRacingDomInteractor {
try { try {
const count = await page.locator(h).first().count().catch(() => 0); const count = await page.locator(h).first().count().catch(() => 0);
if (count > 0) { if (count > 0) {
const tag = await page.locator(h).first().evaluate((el) => el.tagName.toLowerCase()).catch(() => ''); const tag = await page
.locator(h)
.first()
.evaluate((el: any) =>
String((el as any).tagName || '').toLowerCase(),
)
.catch(() => '');
if (tag === 'select') { if (tag === 'select') {
try { try {
await page.selectOption(h, value); await page.selectOption(h, value);
@@ -514,7 +544,11 @@ export class IRacingDomInteractor {
const count = await locator.count().catch(() => 0); const count = await locator.count().catch(() => 0);
if (count === 0) continue; if (count === 0) continue;
const tagName = await locator.evaluate((el) => el.tagName.toLowerCase()).catch(() => ''); const tagName = await locator
.evaluate((el: any) =>
String((el as any).tagName || '').toLowerCase(),
)
.catch(() => '');
const type = await locator.getAttribute('type').catch(() => ''); const type = await locator.getAttribute('type').catch(() => '');
if (tagName === 'input' && (type === 'checkbox' || type === 'radio')) { if (tagName === 'input' && (type === 'checkbox' || type === 'radio')) {
@@ -648,7 +682,11 @@ export class IRacingDomInteractor {
const count = await locator.count().catch(() => 0); const count = await locator.count().catch(() => 0);
if (count === 0) continue; if (count === 0) continue;
const tagName = await locator.evaluate((el) => el.tagName.toLowerCase()).catch(() => ''); const tagName = await locator
.evaluate((el: any) =>
String((el as any).tagName || '').toLowerCase(),
)
.catch(() => '');
if (tagName === 'input') { if (tagName === 'input') {
const type = await locator.getAttribute('type').catch(() => ''); const type = await locator.getAttribute('type').catch(() => '');
if (type === 'range' || type === 'text' || type === 'number') { if (type === 'range' || type === 'text' || type === 'number') {
@@ -746,7 +784,7 @@ export class IRacingDomInteractor {
const addCarButtonSelector = this.isRealMode() const addCarButtonSelector = this.isRealMode()
? IRACING_SELECTORS.steps.addCarButton ? IRACING_SELECTORS.steps.addCarButton
: '[data-action="add-car"]'; : `${IRACING_SELECTORS.steps.addCarButton}, [data-action="add-car"]`;
try { try {
this.log('info', 'Clicking Add Car button to open modal'); this.log('info', 'Clicking Add Car button to open modal');

View File

@@ -97,20 +97,28 @@ export const IRACING_SELECTORS = {
leagueRacingToggle: '#set-session-information .switch-checkbox, [data-toggle="leagueRacing"]', leagueRacingToggle: '#set-session-information .switch-checkbox, [data-toggle="leagueRacing"]',
// Step 4: Server Details // Step 4: Server Details
region: '#set-server-details select.form-control, #set-server-details [data-dropdown="region"], #set-server-details [data-dropdown], [data-dropdown="region"]', region:
startNow: '#set-server-details .switch-checkbox, #set-server-details input[type="checkbox"], [data-toggle="startNow"], input[data-toggle="startNow"]', '#set-server-details select.form-control, ' +
'#set-server-details [data-dropdown="region"], ' +
'#set-server-details [data-dropdown], ' +
'[data-dropdown="region"], ' +
'#set-server-details [role="radiogroup"] input[type="radio"]',
startNow:
'#set-server-details .switch-checkbox, ' +
'#set-server-details input[type="checkbox"], ' +
'[data-toggle="startNow"], ' +
'input[data-toggle="startNow"]',
// Step 5/6: Admins // Step 5/6: Admins
adminSearch: 'input[placeholder*="Search"]', adminSearch: 'input[placeholder*="Search"]',
adminList: '#set-admins table.table.table-striped, #set-admins .card-block table', adminList: '#set-admins table.table.table-striped, #set-admins .card-block table',
addAdminButton: 'a.btn:has-text("Add an Admin")', addAdminButton: 'a.btn:has-text("Add an Admin")',
// Step 7: Time Limits - Bootstrap-slider uses hidden input[type="text"] with id containing slider name // Step 7: Time Limits - Bootstrap-slider uses hidden input[type="text"] with dynamic id
// Also targets the visible slider handle for interaction // Fixtures show ids like time-limit-slider1764248520320
// Dumps show dynamic IDs like time-limit-slider1763726367635 practice: '#set-time-limit input[id*="time-limit-slider"]',
practice: 'label:has-text("Practice") ~ div input[id*="time-limit-slider"]', qualify: '#set-time-limit input[id*="time-limit-slider"]',
qualify: 'label:has-text("Qualify") ~ div input[id*="time-limit-slider"]', race: '#set-time-limit input[id*="time-limit-slider"]',
race: 'label:has-text("Race") ~ div input[id*="time-limit-slider"]',
// Step 8/9: Cars // Step 8/9: Cars
carSearch: 'input[placeholder*="Search"]', carSearch: 'input[placeholder*="Search"]',

View File

@@ -1,8 +1,8 @@
import { IAutomationEngine, ValidationResult } from '../../../application/ports/IAutomationEngine'; import { IAutomationEngine, ValidationResult } from '../../../../application/ports/IAutomationEngine';
import { HostedSessionConfig } from '../../../domain/entities/HostedSessionConfig'; import { HostedSessionConfig } from '../../../../domain/entities/HostedSessionConfig';
import { StepId } from '../../../domain/value-objects/StepId'; import { StepId } from '../../../../domain/value-objects/StepId';
import type { IBrowserAutomation } from '../../../application/ports/IScreenAutomation'; import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation';
import { ISessionRepository } from '../../../application/ports/ISessionRepository'; import { ISessionRepository } from '../../../../application/ports/ISessionRepository';
import { getStepName } from './templates/IRacingTemplateMap'; import { getStepName } from './templates/IRacingTemplateMap';
/** /**
@@ -14,12 +14,13 @@ import { getStepName } from './templates/IRacingTemplateMap';
* 3. Managing session state transitions * 3. Managing session state transitions
* *
* This is a REAL implementation that uses actual automation, * This is a REAL implementation that uses actual automation,
* not a mock. Currently delegates to deprecated nut.js adapters for * not a mock. Historically delegated to legacy native screen
* screen automation operations. * automation adapters, but those are no longer part of the
* supported stack.
* *
* @deprecated This adapter currently delegates to the deprecated NutJsAutomationAdapter. * @deprecated This adapter should be updated to use Playwright
* Should be updated to use Playwright browser automation when available. * browser automation when available. See docs/ARCHITECTURE.md
* See docs/ARCHITECTURE.md for the updated automation strategy. * for the updated automation strategy.
*/ */
export class AutomationEngineAdapter implements IAutomationEngine { export class AutomationEngineAdapter implements IAutomationEngine {
private isRunning = false; private isRunning = false;

View File

@@ -1,8 +1,8 @@
import { IAutomationEngine, ValidationResult } from '../../../application/ports/IAutomationEngine'; import { IAutomationEngine, ValidationResult } from '../../../../application/ports/IAutomationEngine';
import { HostedSessionConfig } from '../../../domain/entities/HostedSessionConfig'; import { HostedSessionConfig } from '../../../../domain/entities/HostedSessionConfig';
import { StepId } from '../../../domain/value-objects/StepId'; import { StepId } from '../../../../domain/value-objects/StepId';
import type { IBrowserAutomation } from '../../../application/ports/IScreenAutomation'; import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation';
import { ISessionRepository } from '../../../application/ports/ISessionRepository'; import { ISessionRepository } from '../../../../application/ports/ISessionRepository';
import { getStepName } from './templates/IRacingTemplateMap'; import { getStepName } from './templates/IRacingTemplateMap';
export class MockAutomationEngineAdapter implements IAutomationEngine { export class MockAutomationEngineAdapter implements IAutomationEngine {
@@ -68,9 +68,14 @@ export class MockAutomationEngineAdapter implements IAutomationEngine {
// Execute current step using the browser automation // Execute current step using the browser automation
if (this.browserAutomation.executeStep) { if (this.browserAutomation.executeStep) {
// Use real workflow automation with IRacingSelectorMap // Use real workflow automation with IRacingSelectorMap
const result = await this.browserAutomation.executeStep(currentStep, config as unknown as Record<string, unknown>); const result = await this.browserAutomation.executeStep(
currentStep,
config as unknown as Record<string, unknown>,
);
if (!result.success) { if (!result.success) {
const errorMessage = `Step ${currentStep.value} (${getStepName(currentStep.value)}) failed: ${result.error}`; const errorMessage = `Step ${currentStep.value} (${getStepName(
currentStep.value,
)}) failed: ${result.error}`;
console.error(errorMessage); console.error(errorMessage);
// Stop automation and mark session as failed // Stop automation and mark session as failed
@@ -95,9 +100,14 @@ export class MockAutomationEngineAdapter implements IAutomationEngine {
if (nextStep.isFinalStep()) { if (nextStep.isFinalStep()) {
// Execute final step handler // Execute final step handler
if (this.browserAutomation.executeStep) { if (this.browserAutomation.executeStep) {
const result = await this.browserAutomation.executeStep(nextStep, config as unknown as Record<string, unknown>); const result = await this.browserAutomation.executeStep(
nextStep,
config as unknown as Record<string, unknown>,
);
if (!result.success) { if (!result.success) {
const errorMessage = `Step ${nextStep.value} (${getStepName(nextStep.value)}) failed: ${result.error}`; const errorMessage = `Step ${nextStep.value} (${getStepName(
nextStep.value,
)}) failed: ${result.error}`;
console.error(errorMessage); console.error(errorMessage);
// Don't try to fail terminal session - just log the error // Don't try to fail terminal session - just log the error
// Session is already in STOPPED_AT_STEP_18 state after transitionToStep() // Session is already in STOPPED_AT_STEP_18 state after transitionToStep()
@@ -118,6 +128,19 @@ export class MockAutomationEngineAdapter implements IAutomationEngine {
} catch (error) { } catch (error) {
console.error('Automation error:', error); console.error('Automation error:', error);
this.isRunning = false; this.isRunning = false;
try {
const sessions = await this.sessionRepository.findAll();
const session = sessions[0];
if (session && !session.state.isTerminal()) {
const message =
error instanceof Error ? error.message : String(error);
session.fail(`Automation error: ${message}`);
await this.sessionRepository.update(session);
}
} catch {
}
return; return;
} }
} }

View File

@@ -1,6 +1,5 @@
import { StepId } from '../../../domain/value-objects/StepId'; import { StepId } from '../../../../domain/value-objects/StepId';
import { HostedSessionConfig } from '../../../domain/entities/HostedSessionConfig'; import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation';
import type { IBrowserAutomation } from '../../../application/ports/IScreenAutomation';
import { import {
NavigationResult, NavigationResult,
FormFillResult, FormFillResult,
@@ -8,7 +7,7 @@ import {
WaitResult, WaitResult,
ModalResult, ModalResult,
AutomationResult, AutomationResult,
} from '../../../application/ports/AutomationResults'; } from '../../../../application/ports/AutomationResults';
interface MockConfig { interface MockConfig {
simulateFailures?: boolean; simulateFailures?: boolean;

View File

@@ -5,8 +5,8 @@
* allowing switching between different adapters based on NODE_ENV. * allowing switching between different adapters based on NODE_ENV.
* *
* Mapping: * Mapping:
* - NODE_ENV=production → NutJsAutomationAdapter → iRacing Window → Image Templates * - NODE_ENV=production → real browser automation → iRacing Window → Image Templates
* - NODE_ENV=development → NutJsAutomationAdapter → iRacing Window → Image Templates * - NODE_ENV=development → real browser automation → iRacing Window → Image Templates
* - NODE_ENV=test → MockBrowserAutomation → N/A → N/A * - NODE_ENV=test → MockBrowserAutomation → N/A → N/A
*/ */
@@ -68,7 +68,7 @@ export const DEFAULT_TIMING_CONFIG: TimingConfig = {
export interface AutomationEnvironmentConfig { export interface AutomationEnvironmentConfig {
mode: AutomationMode; mode: AutomationMode;
/** Production mode configuration (nut.js) */ /** Production/development configuration for native automation */
nutJs?: { nutJs?: {
mouseSpeed?: number; mouseSpeed?: number;
keyboardDelay?: number; keyboardDelay?: number;
@@ -124,7 +124,7 @@ export function getAutomationMode(): AutomationMode {
* Environment variables: * Environment variables:
* - NODE_ENV: 'production' | 'test' (default: 'test') * - NODE_ENV: 'production' | 'test' (default: 'test')
* - AUTOMATION_MODE: (deprecated) 'dev' | 'production' | 'mock' * - AUTOMATION_MODE: (deprecated) 'dev' | 'production' | 'mock'
* - IRACING_WINDOW_TITLE: Window title for nut.js (default: 'iRacing') * - IRACING_WINDOW_TITLE: Window title for native automation (default: 'iRacing')
* - TEMPLATE_PATH: Path to template images (default: './resources/templates') * - TEMPLATE_PATH: Path to template images (default: './resources/templates')
* - OCR_CONFIDENCE: OCR confidence threshold (default: 0.9) * - OCR_CONFIDENCE: OCR confidence threshold (default: 0.9)
* - AUTOMATION_TIMEOUT: Default timeout in ms (default: 30000) * - AUTOMATION_TIMEOUT: Default timeout in ms (default: 30000)

View File

@@ -1,396 +1,14 @@
// Set DISPLAY_SCALE_FACTOR=1 BEFORE any imports that use it import { describe } from 'vitest';
// Templates are already at 2x Retina resolution from macOS screenshot tool
// So we don't want to scale them again
process.env.DISPLAY_SCALE_FACTOR = '1';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { spawn, ChildProcess } from 'child_process';
import { AutomationSession } from '../../packages/domain/entities/AutomationSession';
import { StartAutomationSessionUseCase } from '../../packages/application/use-cases/StartAutomationSessionUseCase';
import { InMemorySessionRepository } from '../../packages/infrastructure/repositories/InMemorySessionRepository';
import { NutJsAutomationAdapter } from '../../packages/infrastructure/adapters/automation/NutJsAutomationAdapter';
import { AutomationEngineAdapter } from '../../packages/infrastructure/adapters/automation/AutomationEngineAdapter';
import { NoOpLogAdapter } from '../../packages/infrastructure/adapters/logging/NoOpLogAdapter';
import type { IScreenAutomation } from '../../packages/application/ports/IScreenAutomation';
import type { ISessionRepository } from '../../packages/application/ports/ISessionRepository';
import { StepId } from '../../packages/domain/value-objects/StepId';
import { permissionGuard, shouldSkipRealAutomationTests } from './support/PermissionGuard';
/** /**
* E2E Tests for REAL Automation against REAL iRacing Website * Legacy real automation smoke suite.
* *
* ZERO MOCKS - REAL AUTOMATION against the REAL iRacing website. * Native OS-level automation has been removed.
* Real iRacing automation is not currently supported.
* *
* These tests: * This file is retained only as historical documentation and is
* 1. Launch a REAL Chrome browser to https://members-ng.iracing.com/web/racing/hosted/browse-sessions * explicitly skipped so it does not participate in normal E2E runs.
* 2. Use REAL NutJsAutomationAdapter with REAL nut.js mouse/keyboard
* 3. Use REAL TemplateMatchingService with REAL OpenCV template matching
* 4. Capture REAL screenshots from the ACTUAL display
*
* Tests will FAIL if:
* - Permissions not granted (macOS accessibility/screen recording)
* - User is NOT logged into iRacing
* - Template images don't match the real iRacing UI
* - Real automation cannot execute
*
* PREREQUISITES:
* - User must be logged into iRacing in their default browser
* - macOS accessibility and screen recording permissions must be granted
*/ */
describe.skip('Real automation smoke REAL iRacing Website (native automation removed)', () => {
const IRACING_URL = 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions'; // No-op: native OS-level real automation has been removed.
const WINDOW_TITLE_PATTERN = 'iRacing'; });
const RUN_REAL_AUTOMATION_SMOKE = process.env.RUN_REAL_AUTOMATION_SMOKE === '1';
const describeSmoke = RUN_REAL_AUTOMATION_SMOKE ? describe : describe.skip;
let skipReason: string | null = null;
let browserProcess: ChildProcess | null = null;
// Configurable wait time for page load (iRacing has heavy JavaScript)
const PAGE_LOAD_WAIT_MS = 10000; // 10 seconds for page to fully load
/**
* Launch the DEFAULT browser to iRacing website using macOS `open` command.
* This opens the URL in the user's default browser where they are already logged in.
*/
async function launchBrowserToIRacing(): Promise<{ success: boolean; pid?: number; error?: string }> {
// Use macOS `open` command to open URL in the DEFAULT browser
// No -a flag = uses the system default browser
browserProcess = spawn('open', [IRACING_URL], {
detached: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
// Wait for browser to start and page to fully load
// iRacing's pages have heavy JavaScript that takes time to render
console.log(`⏳ Waiting ${PAGE_LOAD_WAIT_MS / 1000} seconds for iRacing page to fully load...`);
await new Promise(resolve => setTimeout(resolve, PAGE_LOAD_WAIT_MS));
console.log('✓ Page load wait complete');
// The `open` command returns immediately
if (browserProcess.pid) {
return { success: true, pid: browserProcess.pid };
}
return { success: false, error: 'Failed to open default browser' };
}
/**
* Close the browser window.
* Note: Since we use `open` command, we can't directly kill the browser.
* The browser will remain open after tests complete.
*/
async function closeBrowser(): Promise<void> {
// The `open` command spawns a separate process, so browserProcess
// is just the `open` command itself, not the browser.
// We leave the browser open so the user can inspect the state.
browserProcess = null;
}
describeSmoke('Real automation smoke REAL iRacing Website', () => {
beforeAll(async () => {
// Check permissions first
skipReason = await shouldSkipRealAutomationTests() ?? null;
if (skipReason) {
console.warn('\n⚠ E2E tests will be skipped due to:', skipReason);
return;
}
console.log('\n✓ Permissions verified - ready for REAL automation tests');
// Launch browser to REAL iRacing website
console.log('🏎️ Launching Chrome browser to REAL iRacing website...');
console.log(`🌐 URL: ${IRACING_URL}`);
const launchResult = await launchBrowserToIRacing();
if (!launchResult.success) {
skipReason = `Failed to launch browser: ${launchResult.error}`;
console.warn(`\n⚠ ${skipReason}`);
return;
}
console.log(`✓ Browser launched (PID: ${launchResult.pid})`);
console.log('⚠️ IMPORTANT: You must be logged into iRacing for tests to work!');
});
afterAll(async () => {
if (browserProcess) {
console.log('\n🛑 Closing browser...');
await closeBrowser();
console.log('✓ Browser closed');
}
});
describe('Permission and Environment Checks', () => {
it('should verify permission status', async () => {
const result = await permissionGuard.checkPermissions();
console.log('\n📋 Permission Status:');
console.log(permissionGuard.formatStatus(result.status));
expect(result).toBeDefined();
expect(result.status).toBeDefined();
});
});
describe('Real Automation against REAL iRacing', () => {
let sessionRepository: ISessionRepository;
let screenAutomation: IScreenAutomation;
let startAutomationUseCase: StartAutomationSessionUseCase;
beforeEach(async () => {
if (skipReason) {
return;
}
// Create real session repository
sessionRepository = new InMemorySessionRepository();
// Create REAL nut.js adapter - NO MOCKS
// Using shorter timeouts for E2E tests to fail fast when templates don't match
const logger = new NoOpLogAdapter();
const nutJsAdapter = new NutJsAutomationAdapter(
{
windowTitle: WINDOW_TITLE_PATTERN,
templatePath: './resources/templates/iracing',
defaultTimeout: 5000, // 5 seconds max per operation
mouseSpeed: 500,
keyboardDelay: 30,
retry: {
maxRetries: 1, // Only 1 retry in E2E tests for faster feedback
baseDelayMs: 200,
maxDelayMs: 1000,
backoffMultiplier: 1.5,
},
timing: {
pageLoadWaitMs: 2000,
interActionDelayMs: 100,
postClickDelayMs: 200,
preStepDelayMs: 50,
},
},
logger
);
// Use the REAL adapter directly
screenAutomation = nutJsAdapter;
// Create REAL automation engine
const automationEngine = new AutomationEngineAdapter(
screenAutomation,
sessionRepository
);
// Create use case
startAutomationUseCase = new StartAutomationSessionUseCase(
automationEngine,
screenAutomation,
sessionRepository
);
});
afterEach(async () => {
if (screenAutomation && 'disconnect' in screenAutomation) {
try {
await (screenAutomation as NutJsAutomationAdapter).disconnect();
} catch {
// Ignore
}
}
});
it('should connect to REAL screen automation', async () => {
if (skipReason) {
console.log(`⏭️ Skipped: ${skipReason}`);
return;
}
const connectResult = await screenAutomation.connect?.();
console.log('\n🔌 Connect Result:', connectResult);
expect(connectResult).toBeDefined();
if (!connectResult?.success) {
throw new Error(
`REAL SCREEN AUTOMATION FAILED TO CONNECT.\n` +
`Error: ${connectResult?.error}\n` +
`This requires macOS accessibility and screen recording permissions.`
);
}
console.log('✓ REAL screen automation connected successfully');
});
it('should capture REAL screen showing iRacing website', async () => {
if (skipReason) {
console.log(`⏭️ Skipped: ${skipReason}`);
return;
}
const connectResult = await screenAutomation.connect?.();
if (!connectResult?.success) {
throw new Error(`Failed to connect: ${connectResult?.error}`);
}
const captureResult = await screenAutomation.captureScreen?.();
console.log('\n📸 Screen Capture Result:', {
success: captureResult?.success,
hasData: !!captureResult?.data,
dataLength: captureResult?.data?.length,
});
expect(captureResult).toBeDefined();
if (!captureResult?.success) {
throw new Error(
`REAL SCREEN CAPTURE FAILED.\n` +
`Error: ${captureResult?.error}\n` +
`The iRacing website should be visible in Chrome.`
);
}
expect(captureResult.data).toBeDefined();
expect(captureResult.data?.length).toBeGreaterThan(0);
console.log(`✓ REAL screen captured: ${captureResult.data?.length} bytes`);
console.log(' This screenshot contains the REAL iRacing website!');
});
it('should focus the iRacing browser window', async () => {
if (skipReason) {
console.log(`⏭️ Skipped: ${skipReason}`);
return;
}
const connectResult = await screenAutomation.connect?.();
if (!connectResult?.success) {
throw new Error(`Failed to connect: ${connectResult?.error}`);
}
console.log('\n🔍 Attempting to focus iRacing browser window...');
const focusResult = await screenAutomation.focusBrowserWindow?.(WINDOW_TITLE_PATTERN);
console.log('Focus Result:', focusResult);
// This may fail if iRacing window doesn't have expected title
if (!focusResult?.success) {
console.log(`⚠️ Could not focus window: ${focusResult?.error}`);
console.log(' Make sure iRacing website is displayed in Chrome');
} else {
console.log('✓ Browser window focused');
}
expect(focusResult).toBeDefined();
});
it('should attempt REAL step 2 automation against iRacing website', async () => {
if (skipReason) {
console.log(`⏭️ Skipped: ${skipReason}`);
return;
}
const connectResult = await screenAutomation.connect?.();
if (!connectResult?.success) {
throw new Error(`Failed to connect: ${connectResult?.error}`);
}
const stepId = StepId.create(2);
const config = {
sessionName: 'E2E Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
};
console.log('\n🏎 Executing REAL step 2 automation against iRacing website...');
console.log(' This uses REAL nut.js + OpenCV template matching');
console.log(' Looking for UI elements in the REAL iRacing website');
const result = await screenAutomation.executeStep?.(stepId, config);
console.log('\n📊 Step Execution Result:', result);
// We EXPECT this to either:
// 1. FAIL because templates don't match the real iRacing UI
// 2. FAIL because user is not logged in
// 3. SUCCEED if templates match and user is logged in
if (result?.success) {
console.log('\n✓ STEP 2 SUCCEEDED!');
console.log(' Real automation found and clicked UI elements in iRacing!');
} else {
console.log(`\n✗ Step 2 failed: ${result?.error}`);
console.log(' This is expected if:');
console.log(' - User is NOT logged into iRacing');
console.log(' - Templates don\'t match the real iRacing UI');
}
// Test passes - we verified real automation was attempted
expect(result).toBeDefined();
});
it('should execute REAL workflow against iRacing website', async () => {
if (skipReason) {
console.log(`⏭️ Skipped: ${skipReason}`);
return;
}
const connectResult = await screenAutomation.connect?.();
if (!connectResult?.success) {
throw new Error(`Failed to connect: ${connectResult?.error}`);
}
const sessionConfig = {
sessionName: 'E2E Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
};
const session = AutomationSession.create(sessionConfig);
session.start();
await sessionRepository.save(session);
console.log('\n🏎 Starting REAL workflow automation against iRacing website...');
console.log(' Each step uses REAL template matching and mouse/keyboard');
console.log(' Against the REAL iRacing website in Chrome');
const stepResults: Array<{ step: number; success: boolean; error?: string }> = [];
for (let step = 2; step <= 5; step++) {
console.log(`\n → Attempting REAL step ${step}...`);
const result = await screenAutomation.executeStep?.(StepId.create(step), sessionConfig);
stepResults.push({
step,
success: result?.success ?? false,
error: result?.error,
});
console.log(` Result: ${result?.success ? '✓' : '✗'} ${result?.error || 'Success'}`);
if (!result?.success) {
break;
}
}
console.log('\n📊 Workflow Results:', stepResults);
const allSucceeded = stepResults.every(r => r.success);
const failedStep = stepResults.find(r => !r.success);
if (allSucceeded) {
console.log('\n✓ ALL STEPS SUCCEEDED against iRacing website!');
} else {
console.log(`\n✗ Workflow stopped at step ${failedStep?.step}: ${failedStep?.error}`);
console.log(' This is expected if user is not logged in or templates need updating');
}
expect(stepResults.length).toBeGreaterThan(0);
});
});
});

View File

@@ -1,737 +1,8 @@
import { Given, When, Then, Before, After, BeforeAll, AfterAll } from '@cucumber/cucumber';
import { expect } from 'vitest';
import { spawn, ChildProcess } from 'child_process';
import { existsSync } from 'fs';
import { AutomationSession } from '../../../packages/domain/entities/AutomationSession';
import { StartAutomationSessionUseCase } from '../../../packages/application/use-cases/StartAutomationSessionUseCase';
import { InMemorySessionRepository } from '../../../packages/infrastructure/repositories/InMemorySessionRepository';
import { NutJsAutomationAdapter } from '../../../packages/infrastructure/adapters/automation/NutJsAutomationAdapter';
import { AutomationEngineAdapter } from '../../../packages/infrastructure/adapters/automation/AutomationEngineAdapter';
import { NoOpLogAdapter } from '../../../packages/infrastructure/adapters/logging/NoOpLogAdapter';
import type { IScreenAutomation } from '../../../packages/application/ports/IScreenAutomation';
import type { ISessionRepository } from '../../../packages/application/ports/ISessionRepository';
import { StepId } from '../../../packages/domain/value-objects/StepId';
import { permissionGuard, shouldSkipRealAutomationTests } from '../support/PermissionGuard';
/** /**
* E2E Test Context - REAL iRacing Website Automation * Legacy Cucumber step definitions for real iRacing automation.
* *
* ZERO MOCKS. ZERO FIXTURES. ZERO FAKE HTML PAGES. * Native OS-level automation and these steps have been retired.
* * This file is excluded from TypeScript builds and is kept only as
* Uses 100% REAL adapters against the REAL iRacing website: * historical documentation. No executable step definitions remain.
* - NutJsAutomationAdapter: REAL nut.js mouse/keyboard automation
* - Real screen capture and template matching via OpenCV
* - InMemorySessionRepository: Real in-memory persistence (not a mock)
* - REAL Chrome browser pointing to https://members.iracing.com
*
* These tests run against the REAL iRacing website and require:
* - macOS with display access
* - Accessibility permissions for nut.js
* - Screen Recording permissions
* - User must be LOGGED INTO iRacing (tests cannot automate login)
*
* If these requirements are not met, tests will be SKIPPED gracefully.
*/ */
interface TestContext { export {};
sessionRepository: ISessionRepository;
screenAutomation: IScreenAutomation;
startAutomationUseCase: StartAutomationSessionUseCase;
currentSession: AutomationSession | null;
sessionConfig: Record<string, unknown>;
error: Error | null;
startTime: number;
skipReason: string | null;
stepResults: Array<{ step: number; success: boolean; error?: string }>;
}
/**
* Real iRacing website URL.
*/
const IRACING_URL = 'https://members.iracing.com/membersite/member/Series.do';
/**
* Window title pattern for real iRacing browser.
*/
const IRACING_WINDOW_TITLE = 'iRacing';
/**
* Global state for browser and skip reason (shared across scenarios)
*/
let globalBrowserProcess: ChildProcess | null = null;
let globalSkipReason: string | null = null;
/**
* Find Chrome executable path.
*/
function findChromePath(): string | null {
const paths = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
];
for (const path of paths) {
if (existsSync(path)) {
return path;
}
}
return null;
}
/**
* Launch real Chrome browser to iRacing website.
*/
async function launchBrowserToIRacing(): Promise<{ success: boolean; pid?: number; error?: string }> {
const chromePath = findChromePath();
if (!chromePath) {
return { success: false, error: 'Chrome not found' };
}
const args = [
'--disable-extensions',
'--disable-plugins',
'--window-position=0,0',
'--window-size=1920,1080',
'--no-first-run',
'--no-default-browser-check',
IRACING_URL,
];
globalBrowserProcess = spawn(chromePath, args, {
detached: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
// Wait for browser to start
await new Promise(resolve => setTimeout(resolve, 5000));
if (globalBrowserProcess.pid) {
return { success: true, pid: globalBrowserProcess.pid };
}
return { success: false, error: 'Browser process failed to start' };
}
/**
* Close the browser process.
*/
async function closeBrowser(): Promise<void> {
if (!globalBrowserProcess) return;
return new Promise(resolve => {
if (!globalBrowserProcess) {
resolve();
return;
}
globalBrowserProcess.once('exit', () => {
globalBrowserProcess = null;
resolve();
});
globalBrowserProcess.kill('SIGTERM');
setTimeout(() => {
if (globalBrowserProcess) {
globalBrowserProcess.kill('SIGKILL');
globalBrowserProcess = null;
resolve();
}
}, 3000);
});
}
/**
* BeforeAll: Check permissions and launch real browser to iRacing.
*/
BeforeAll(async function () {
globalSkipReason = await shouldSkipRealAutomationTests() ?? null;
if (globalSkipReason) {
console.warn('\n⚠ E2E tests will be SKIPPED due to permission/environment issues');
return;
}
console.log('\n✓ Permissions verified - ready for REAL automation tests');
console.log('🏎️ Launching REAL Chrome browser to iRacing website...');
// Launch real browser to real iRacing website
const launchResult = await launchBrowserToIRacing();
if (!launchResult.success) {
globalSkipReason = `Failed to launch browser: ${launchResult.error}`;
console.warn(`\n⚠ ${globalSkipReason}`);
return;
}
console.log(`✓ Browser launched (PID: ${launchResult.pid})`);
console.log(`🌐 Navigated to: ${IRACING_URL}`);
console.log('⚠️ IMPORTANT: You must be logged into iRacing for tests to work!');
});
/**
* AfterAll: Close browser.
*/
AfterAll(async function () {
if (globalBrowserProcess) {
console.log('\n🛑 Closing browser...');
await closeBrowser();
console.log('✓ Browser closed');
}
});
Before(async function (this: TestContext) {
this.skipReason = globalSkipReason;
if (this.skipReason) {
return 'skipped';
}
process.env.NODE_ENV = 'test';
// Create real session repository
this.sessionRepository = new InMemorySessionRepository();
// Create REAL nut.js automation adapter - NO MOCKS
const logger = new NoOpLogAdapter();
const nutJsAdapter = new NutJsAutomationAdapter(
{
windowTitle: IRACING_WINDOW_TITLE,
templatePath: './resources/templates/iracing',
defaultTimeout: 10000,
mouseSpeed: 500,
keyboardDelay: 30,
},
logger
);
// Use the REAL adapter directly
this.screenAutomation = nutJsAdapter;
// Connect to REAL automation
const connectResult = await this.screenAutomation.connect?.();
if (connectResult && !connectResult.success) {
this.skipReason = `Failed to connect REAL automation: ${connectResult.error}`;
return 'skipped';
}
// Create REAL automation engine
const automationEngine = new AutomationEngineAdapter(
this.screenAutomation,
this.sessionRepository
);
// Create use case with REAL adapters
this.startAutomationUseCase = new StartAutomationSessionUseCase(
automationEngine,
this.screenAutomation,
this.sessionRepository
);
// Initialize test state
this.currentSession = null;
this.sessionConfig = {};
this.error = null;
this.startTime = 0;
this.stepResults = [];
});
After(async function (this: TestContext) {
// Log step results if any
if (this.stepResults && this.stepResults.length > 0) {
console.log('\n📊 Step Execution Results:');
this.stepResults.forEach(r => {
console.log(` Step ${r.step}: ${r.success ? '✓' : '✗'} ${r.error || ''}`);
});
}
// Disconnect REAL automation adapter
if (this.screenAutomation && 'disconnect' in this.screenAutomation) {
try {
await (this.screenAutomation as NutJsAutomationAdapter).disconnect();
} catch {
// Ignore disconnect errors during cleanup
}
}
// Reset test state
this.currentSession = null;
this.sessionConfig = {};
this.error = null;
this.stepResults = [];
});
Given('the companion app is running', function (this: TestContext) {
expect(this.screenAutomation).toBeDefined();
});
Given('I am authenticated with iRacing', function (this: TestContext) {
// In REAL E2E tests, user must already be logged into iRacing
// We cannot automate login for security reasons
console.log('⚠️ Assuming user is logged into iRacing - tests will fail if not');
expect(true).toBe(true);
});
Given('I have a valid session configuration', function (this: TestContext) {
this.sessionConfig = {
sessionName: 'Test Race Session',
trackId: 'spa',
carIds: ['dallara-f3'],
};
});
Given('I have a session configuration with:', function (this: TestContext, dataTable: any) {
const rows = dataTable.rawTable.slice(1);
this.sessionConfig = {};
rows.forEach(([field, value]: [string, string]) => {
if (field === 'carIds') {
this.sessionConfig[field] = value.split(',').map(v => v.trim());
} else {
this.sessionConfig[field] = value;
}
});
});
Given('I have started an automation session', async function (this: TestContext) {
this.sessionConfig = {
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
};
this.currentSession = AutomationSession.create(this.sessionConfig);
this.currentSession.start();
await this.sessionRepository.save(this.currentSession);
});
Given('the automation has reached step {int}', async function (this: TestContext, stepNumber: number) {
expect(this.currentSession).toBeDefined();
for (let i = 2; i <= stepNumber; i++) {
this.currentSession!.transitionToStep(StepId.create(i));
}
await this.sessionRepository.update(this.currentSession!);
});
Given('the automation has progressed to step {int}', async function (this: TestContext, stepNumber: number) {
expect(this.currentSession).toBeDefined();
for (let i = 2; i <= stepNumber; i++) {
this.currentSession!.transitionToStep(StepId.create(i));
}
await this.sessionRepository.update(this.currentSession!);
});
Given('the automation is at step {int}', async function (this: TestContext, stepNumber: number) {
expect(this.currentSession).toBeDefined();
for (let i = 2; i <= stepNumber; i++) {
this.currentSession!.transitionToStep(StepId.create(i));
}
await this.sessionRepository.update(this.currentSession!);
});
Given('the session is in progress', function (this: TestContext) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.state.isInProgress()).toBe(true);
});
When('I start the automation session', async function (this: TestContext) {
try {
const result = await this.startAutomationUseCase.execute(this.sessionConfig);
this.currentSession = await this.sessionRepository.findById(result.sessionId);
this.startTime = Date.now();
} catch (error) {
this.error = error as Error;
}
});
When('I attempt to start the automation session', async function (this: TestContext) {
try {
const result = await this.startAutomationUseCase.execute(this.sessionConfig);
this.currentSession = await this.sessionRepository.findById(result.sessionId);
} catch (error) {
this.error = error as Error;
}
});
When('the automation progresses through all {int} steps', async function (this: TestContext, stepCount: number) {
expect(this.currentSession).toBeDefined();
this.currentSession!.start();
console.log('\n🏎 Executing REAL automation workflow against iRacing...');
console.log(' Each step uses REAL template matching and mouse/keyboard');
console.log(' Against the REAL iRacing website in Chrome');
// Execute each step with REAL automation
for (let i = 2; i <= stepCount; i++) {
console.log(`\n → Executing REAL step ${i}...`);
// Execute the step using REAL automation (nut.js + template matching)
const result = await this.screenAutomation.executeStep?.(StepId.create(i), this.sessionConfig);
this.stepResults.push({
step: i,
success: result?.success ?? false,
error: result?.error,
});
console.log(` Result: ${result?.success ? '✓' : '✗'} ${result?.error || 'Success'}`);
if (result && !result.success) {
// REAL automation failed - this is expected if iRacing isn't properly set up
throw new Error(
`REAL automation failed at step ${i}: ${result.error}\n` +
`This test requires iRacing to be logged in and on the correct page.`
);
}
this.currentSession!.transitionToStep(StepId.create(i));
}
});
When('the automation transitions to step {int}', async function (this: TestContext, stepNumber: number) {
expect(this.currentSession).toBeDefined();
// Execute REAL automation for this step
console.log(`\n → Executing REAL step ${stepNumber}...`);
const result = await this.screenAutomation.executeStep?.(StepId.create(stepNumber), this.sessionConfig);
this.stepResults.push({
step: stepNumber,
success: result?.success ?? false,
error: result?.error,
});
if (result && !result.success) {
console.log(` REAL automation failed: ${result.error}`);
}
this.currentSession!.transitionToStep(StepId.create(stepNumber));
await this.sessionRepository.update(this.currentSession!);
});
When('the {string} modal appears', async function (this: TestContext, modalName: string) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.isAtModalStep()).toBe(true);
});
When('I pause the automation', async function (this: TestContext) {
expect(this.currentSession).toBeDefined();
this.currentSession!.pause();
await this.sessionRepository.update(this.currentSession!);
});
When('I resume the automation', async function (this: TestContext) {
expect(this.currentSession).toBeDefined();
this.currentSession!.resume();
await this.sessionRepository.update(this.currentSession!);
});
When('a browser automation error occurs', async function (this: TestContext) {
expect(this.currentSession).toBeDefined();
this.currentSession!.fail('Browser automation failed at step 8');
await this.sessionRepository.update(this.currentSession!);
});
When('I attempt to skip directly to step {int}', function (this: TestContext, targetStep: number) {
expect(this.currentSession).toBeDefined();
try {
this.currentSession!.transitionToStep(StepId.create(targetStep));
} catch (error) {
this.error = error as Error;
}
});
When('I attempt to move back to step {int}', function (this: TestContext, targetStep: number) {
expect(this.currentSession).toBeDefined();
try {
this.currentSession!.transitionToStep(StepId.create(targetStep));
} catch (error) {
this.error = error as Error;
}
});
When('the automation reaches step {int}', async function (this: TestContext, stepNumber: number) {
expect(this.currentSession).toBeDefined();
for (let i = 2; i <= stepNumber; i++) {
this.currentSession!.transitionToStep(StepId.create(i));
}
await this.sessionRepository.update(this.currentSession!);
});
When('the application restarts', function (this: TestContext) {
const sessionId = this.currentSession!.id;
this.currentSession = null;
this.sessionRepository.findById(sessionId).then(session => {
this.currentSession = session;
});
});
When('I attempt to start another automation session', async function (this: TestContext) {
const newConfig = {
sessionName: 'Second Race',
trackId: 'monza',
carIds: ['porsche-911-gt3'],
};
try {
await this.startAutomationUseCase.execute(newConfig);
} catch (error) {
this.error = error as Error;
}
});
When('the automation runs for {int} seconds', async function (this: TestContext, seconds: number) {
expect(this.currentSession).toBeDefined();
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
});
When('I query the session status', async function (this: TestContext) {
expect(this.currentSession).toBeDefined();
const retrieved = await this.sessionRepository.findById(this.currentSession!.id);
this.currentSession = retrieved;
});
Then('the session should be created with state {string}', function (this: TestContext, expectedState: string) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.state.value).toBe(expectedState);
});
Then('the current step should be {int}', function (this: TestContext, expectedStep: number) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.currentStep.value).toBe(expectedStep);
});
Then('the current step should remain {int}', function (this: TestContext, expectedStep: number) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.currentStep.value).toBe(expectedStep);
});
Then('step {int} should navigate to {string}', function (this: TestContext, stepNumber: number, description: string) {
expect(stepNumber).toBeGreaterThanOrEqual(1);
expect(stepNumber).toBeLessThanOrEqual(18);
const stepResult = this.stepResults.find(r => r.step === stepNumber);
if (!stepResult) {
console.log(`⚠️ Step ${stepNumber} (${description}) was not executed - REAL automation required`);
}
});
Then('step {int} should click {string}', function (this: TestContext, stepNumber: number, description: string) {
expect(stepNumber).toBeGreaterThanOrEqual(1);
expect(stepNumber).toBeLessThanOrEqual(18);
const stepResult = this.stepResults.find(r => r.step === stepNumber);
if (!stepResult) {
console.log(`⚠️ Step ${stepNumber} (${description}) click was not executed - REAL automation required`);
}
});
Then('step {int} should fill {string}', function (this: TestContext, stepNumber: number, description: string) {
expect(stepNumber).toBeGreaterThanOrEqual(1);
expect(stepNumber).toBeLessThanOrEqual(18);
const stepResult = this.stepResults.find(r => r.step === stepNumber);
if (!stepResult) {
console.log(`⚠️ Step ${stepNumber} (${description}) fill was not executed - REAL automation required`);
}
});
Then('step {int} should configure {string}', function (this: TestContext, stepNumber: number, description: string) {
expect(stepNumber).toBeGreaterThanOrEqual(1);
expect(stepNumber).toBeLessThanOrEqual(18);
});
Then('step {int} should access {string}', function (this: TestContext, stepNumber: number, description: string) {
expect(stepNumber).toBeGreaterThanOrEqual(1);
expect(stepNumber).toBeLessThanOrEqual(18);
});
Then('step {int} should handle {string} modal', function (this: TestContext, stepNumber: number, modalName: string) {
expect([6, 9, 12]).toContain(stepNumber);
});
Then('step {int} should set {string}', function (this: TestContext, stepNumber: number, description: string) {
expect(stepNumber).toBeGreaterThanOrEqual(1);
expect(stepNumber).toBeLessThanOrEqual(18);
});
Then('step {int} should reach {string}', function (this: TestContext, stepNumber: number, description: string) {
expect(stepNumber).toBe(18);
});
Then('the session should stop at step {int}', function (this: TestContext, expectedStep: number) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.currentStep.value).toBe(expectedStep);
expect(this.currentSession!.state.isStoppedAtStep18()).toBe(true);
});
Then('the session state should be {string}', function (this: TestContext, expectedState: string) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.state.value).toBe(expectedState);
});
Then('a manual submit warning should be displayed', function (this: TestContext) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.currentStep.isFinalStep()).toBe(true);
});
Then('the automation should detect the modal', function (this: TestContext) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.isAtModalStep()).toBe(true);
});
Then('the automation should wait for modal content to load', async function (this: TestContext) {
expect(this.currentSession).toBeDefined();
});
Then('the automation should fill admin fields', async function (this: TestContext) {
expect(this.currentSession).toBeDefined();
});
Then('the automation should close the modal', async function (this: TestContext) {
expect(this.currentSession).toBeDefined();
});
Then('the automation should transition to step {int}', async function (this: TestContext, nextStep: number) {
expect(this.currentSession).toBeDefined();
this.currentSession!.transitionToStep(StepId.create(nextStep));
});
Then('the automation should select the car {string}', async function (this: TestContext, carId: string) {
expect(this.sessionConfig.carIds).toContain(carId);
});
Then('the automation should confirm the selection', async function (this: TestContext) {
expect(this.currentSession).toBeDefined();
});
Then('the automation should select the track {string}', async function (this: TestContext, trackId: string) {
expect(this.sessionConfig.trackId).toBe(trackId);
});
Then('the automation should automatically stop', function (this: TestContext) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.state.isStoppedAtStep18()).toBe(true);
});
Then('no submit action should be executed', function (this: TestContext) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.state.isStoppedAtStep18()).toBe(true);
});
Then('a notification should inform the user to review before submitting', function (this: TestContext) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.currentStep.isFinalStep()).toBe(true);
});
Then('the automation should continue from step {int}', function (this: TestContext, expectedStep: number) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.currentStep.value).toBe(expectedStep);
});
Then('an error message should be recorded', function (this: TestContext) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.errorMessage).toBeDefined();
});
Then('the session should have a completedAt timestamp', function (this: TestContext) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.completedAt).toBeDefined();
});
Then('the user should be notified of the failure', function (this: TestContext) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.state.isFailed()).toBe(true);
});
Then('the session creation should fail', function (this: TestContext) {
expect(this.error).toBeDefined();
});
Then('an error message should indicate {string}', function (this: TestContext, expectedMessage: string) {
expect(this.error).toBeDefined();
expect(this.error!.message).toContain(expectedMessage);
});
Then('no session should be persisted', async function (this: TestContext) {
const sessions = await this.sessionRepository.findAll();
expect(sessions).toHaveLength(0);
});
Then('the transition should be rejected', function (this: TestContext) {
expect(this.error).toBeDefined();
});
Then('all three cars should be added via the modal', function (this: TestContext) {
expect(this.sessionConfig.carIds).toHaveLength(3);
});
Then('the automation should handle the modal three times', function (this: TestContext) {
expect(this.sessionConfig.carIds).toHaveLength(3);
});
Then('the session should be recoverable from storage', async function (this: TestContext) {
expect(this.currentSession).toBeDefined();
});
Then('the session configuration should be intact', function (this: TestContext) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.config).toBeDefined();
});
Then('the second session creation should be queued or rejected', function (this: TestContext) {
expect(this.error).toBeDefined();
});
Then('a warning should inform about the active session', function (this: TestContext) {
expect(this.error).toBeDefined();
});
Then('the elapsed time should be approximately {int} milliseconds', function (this: TestContext, expectedMs: number) {
expect(this.currentSession).toBeDefined();
const elapsed = this.currentSession!.getElapsedTime();
expect(elapsed).toBeGreaterThanOrEqual(expectedMs - 1000);
expect(elapsed).toBeLessThanOrEqual(expectedMs + 1000);
});
Then('the elapsed time should increase while in progress', function (this: TestContext) {
expect(this.currentSession).toBeDefined();
const elapsed = this.currentSession!.getElapsedTime();
expect(elapsed).toBeGreaterThan(0);
});
Then('each step should take between {int}ms and {int}ms', function (this: TestContext, minMs: number, maxMs: number) {
expect(minMs).toBeLessThan(maxMs);
// In REAL automation, timing depends on actual screen/mouse operations
});
Then('modal steps should take longer than regular steps', function (this: TestContext) {
// In REAL automation, modal steps involve more operations
});
Then('the total workflow should complete in under {int} seconds', function (this: TestContext, maxSeconds: number) {
expect(this.currentSession).toBeDefined();
const elapsed = this.currentSession!.getElapsedTime();
expect(elapsed).toBeLessThan(maxSeconds * 1000);
});
Then('the session should stop at step {int} without submitting', function (this: TestContext, expectedStep: number) {
expect(this.currentSession).toBeDefined();
expect(this.currentSession!.currentStep.value).toBe(expectedStep);
expect(this.currentSession!.state.isStoppedAtStep18()).toBe(true);
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { StepHarness } from '../support/StepHarness'; import type { StepHarness } from '../support/StepHarness';
import { createStepHarness } from '../support/StepHarness'; import { createStepHarness } from '../support/StepHarness';
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
describe('Step 2 create race', () => { describe('Step 2 create race', () => {
let harness: StepHarness; let harness: StepHarness;
@@ -13,20 +14,37 @@ describe('Step 2 create race', () => {
await harness.dispose(); await harness.dispose();
}); });
it('clicks Create a Race on Hosted Racing page', async () => { it('opens the real Create Race confirmation modal with Last Settings / New Race options', async () => {
await harness.navigateToFixtureStep(1); await harness.navigateToFixtureStep(2);
const page = harness.adapter.getPage(); const page = harness.adapter.getPage();
expect(page).not.toBeNull(); expect(page).not.toBeNull();
const bodyTextBefore = await page!.textContent('body'); const bodyTextBefore = await page!.textContent('body');
expect(bodyTextBefore).toContain('Create a Race'); expect(bodyTextBefore).toContain('Create a Race');
const result = await harness.executeStep(2, {}); const result = await harness.executeStep(2, {});
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
const bodyTextAfter = await page!.textContent('body'); await page!.waitForSelector(
expect(bodyTextAfter).toMatch(/Last Settings/i); IRACING_SELECTORS.hostedRacing.createRaceModal,
);
const modalText = await page!.textContent(
IRACING_SELECTORS.hostedRacing.createRaceModal,
);
expect(modalText).toMatch(/Last Settings/i);
expect(modalText).toMatch(/New Race/i);
const lastSettingsButton = await page!.$(
IRACING_SELECTORS.hostedRacing.lastSettingsButton,
);
const newRaceButton = await page!.$(
IRACING_SELECTORS.hostedRacing.newRaceButton,
);
expect(lastSettingsButton).not.toBeNull();
expect(newRaceButton).not.toBeNull();
}); });
}); });

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { StepHarness } from '../support/StepHarness'; import type { StepHarness } from '../support/StepHarness';
import { createStepHarness } from '../support/StepHarness'; import { createStepHarness } from '../support/StepHarness';
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
describe('Step 3 race information', () => { describe('Step 3 race information', () => {
let harness: StepHarness; let harness: StepHarness;
@@ -13,26 +14,46 @@ describe('Step 3 race information', () => {
await harness.dispose(); await harness.dispose();
}); });
it('fills race information on Race Information page', async () => { it('fills race information on Race Information page and persists values in form fields', async () => {
await harness.navigateToFixtureStep(3); await harness.navigateToFixtureStep(3);
const page = harness.adapter.getPage(); const page = harness.adapter.getPage();
expect(page).not.toBeNull(); expect(page).not.toBeNull();
const sidebarRaceInfo = await page!.textContent( const sidebarRaceInfo = await page!.textContent(
'#wizard-sidebar-link-set-session-information', '#wizard-sidebar-link-set-session-information',
); );
expect(sidebarRaceInfo).toContain('Race Information'); expect(sidebarRaceInfo).toContain('Race Information');
const result = await harness.executeStep(3, { const config = {
sessionName: 'GridPilot E2E Session', sessionName: 'GridPilot E2E Session',
password: 'secret', password: 'secret',
description: 'Step 3 race information E2E', description: 'Step 3 race information E2E',
}); };
const result = await harness.executeStep(3, config);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
const sessionNameInput = page!
.locator(IRACING_SELECTORS.steps.sessionName)
.first();
const passwordInput = page!
.locator(IRACING_SELECTORS.steps.password)
.first();
const descriptionInput = page!
.locator(IRACING_SELECTORS.steps.description)
.first();
const sessionNameValue = await sessionNameInput.inputValue();
const passwordValue = await passwordInput.inputValue();
const descriptionValue = await descriptionInput.inputValue();
expect(sessionNameValue).toBe(config.sessionName);
expect(passwordValue).toBe(config.password);
expect(descriptionValue).toBe(config.description);
const footerText = await page!.textContent('.wizard-footer'); const footerText = await page!.textContent('.wizard-footer');
expect(footerText).toMatch(/Server Details|Admins/i); expect(footerText).toMatch(/Server Details|Admins/i);
}); });

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { StepHarness } from '../support/StepHarness'; import type { StepHarness } from '../support/StepHarness';
import { createStepHarness } from '../support/StepHarness'; import { createStepHarness } from '../support/StepHarness';
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
describe('Step 4 server details', () => { describe('Step 4 server details', () => {
let harness: StepHarness; let harness: StepHarness;
@@ -13,22 +14,41 @@ describe('Step 4 server details', () => {
await harness.dispose(); await harness.dispose();
}); });
it('executes on Server Details page and progresses toward Admins', async () => { it('executes on Server Details page, applies region/start toggle, and progresses toward Admins', async () => {
await harness.navigateToFixtureStep(4); await harness.navigateToFixtureStep(4);
const page = harness.adapter.getPage(); const page = harness.adapter.getPage();
expect(page).not.toBeNull(); expect(page).not.toBeNull();
const sidebarServerDetails = await page!.textContent( const sidebarServerDetails = await page!.textContent(
'#wizard-sidebar-link-set-server-details', '#wizard-sidebar-link-set-server-details',
); );
expect(sidebarServerDetails).toContain('Server Details'); expect(sidebarServerDetails).toContain('Server Details');
const result = await harness.executeStep(4, {}); const config = {
region: 'US-East-OH',
startNow: true,
};
const result = await harness.executeStep(4, config);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
const currentServerHeader = await page!
.locator('#set-server-details button:has-text("Current Server")')
.first()
.innerText();
expect(currentServerHeader.toLowerCase()).toContain('us-east');
const startToggle = page!
.locator(IRACING_SELECTORS.steps.startNow)
.first();
const startNowChecked =
(await startToggle.getAttribute('checked')) !== null ||
(await startToggle.getAttribute('aria-checked')) === 'true';
expect(startNowChecked).toBe(true);
const footerText = await page!.textContent('.wizard-footer'); const footerText = await page!.textContent('.wizard-footer');
expect(footerText).toMatch(/Admins/i); expect(footerText).toMatch(/Admins/i);
}); });

View File

@@ -13,25 +13,31 @@ describe('Step 5 set admins', () => {
await harness.dispose(); await harness.dispose();
}); });
it('executes on Set Admins page and progresses to Time Limit', async () => { it('executes on Set Admins page and leaves at least one admin in the selected admins table when progressing to Time Limit', async () => {
await harness.navigateToFixtureStep(5); await harness.navigateToFixtureStep(5);
const page = harness.adapter.getPage(); const page = harness.adapter.getPage();
expect(page).not.toBeNull(); expect(page).not.toBeNull();
const sidebarAdmins = await page!.textContent( const sidebarAdmins = await page!.textContent(
'#wizard-sidebar-link-set-admins', '#wizard-sidebar-link-set-admins',
); );
expect(sidebarAdmins).toContain('Admins'); expect(sidebarAdmins).toContain('Admins');
const bodyText = await page!.textContent('body'); const bodyText = await page!.textContent('body');
expect(bodyText).toContain('Add an Admin'); expect(bodyText).toContain('Add an Admin');
const result = await harness.executeStep(5, {}); const result = await harness.executeStep(5, {});
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
const selectedAdminsText =
(await page!.textContent(
'#set-admins tbody[data-testid="admin-display-name-list"]',
)) ?? '';
expect(selectedAdminsText.trim()).not.toEqual('');
const footerText = await page!.textContent('.wizard-footer'); const footerText = await page!.textContent('.wizard-footer');
expect(footerText).toContain('Time Limit'); expect(footerText).toContain('Time Limit');
}); });

View File

@@ -13,7 +13,7 @@ describe('Step 6 admins', () => {
await harness.dispose(); await harness.dispose();
}); });
it('completes successfully from Set Admins page', async () => { it('completes successfully from Set Admins page and leaves selected admins populated', async () => {
await harness.navigateToFixtureStep(5); await harness.navigateToFixtureStep(5);
const page = harness.adapter.getPage(); const page = harness.adapter.getPage();
expect(page).not.toBeNull(); expect(page).not.toBeNull();
@@ -27,11 +27,17 @@ describe('Step 6 admins', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
const selectedAdminsText =
(await page!.textContent(
'#set-admins tbody[data-testid="admin-display-name-list"]',
)) ?? '';
expect(selectedAdminsText.trim()).not.toEqual('');
const footerText = await page!.textContent('.wizard-footer'); const footerText = await page!.textContent('.wizard-footer');
expect(footerText).toContain('Time Limit'); expect(footerText).toContain('Time Limit');
}); });
it('handles Add Admin drawer state without regression', async () => { it('handles Add Admin drawer state without regression and preserves selected admins list', async () => {
await harness.navigateToFixtureStep(6); await harness.navigateToFixtureStep(6);
const page = harness.adapter.getPage(); const page = harness.adapter.getPage();
expect(page).not.toBeNull(); expect(page).not.toBeNull();
@@ -45,6 +51,12 @@ describe('Step 6 admins', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
const selectedAdminsText =
(await page!.textContent(
'#set-admins tbody[data-testid="admin-display-name-list"]',
)) ?? '';
expect(selectedAdminsText.trim()).not.toEqual('');
const footerText = await page!.textContent('.wizard-footer'); const footerText = await page!.textContent('.wizard-footer');
expect(footerText).toContain('Time Limit'); expect(footerText).toContain('Time Limit');
}); });

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { StepHarness } from '../support/StepHarness'; import type { StepHarness } from '../support/StepHarness';
import { createStepHarness } from '../support/StepHarness'; import { createStepHarness } from '../support/StepHarness';
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
describe('Step 7 time limits', () => { describe('Step 7 time limits', () => {
let harness: StepHarness; let harness: StepHarness;
@@ -13,24 +14,36 @@ describe('Step 7 time limits', () => {
await harness.dispose(); await harness.dispose();
}); });
it('executes on Time Limits page and navigates to Cars', async () => { it('executes on Time Limits page, applies sliders, and navigates to Cars', async () => {
await harness.navigateToFixtureStep(7); await harness.navigateToFixtureStep(7);
const page = harness.adapter.getPage(); const page = harness.adapter.getPage();
expect(page).not.toBeNull(); expect(page).not.toBeNull();
const stepIndicatorBefore = await page!.textContent('[data-indicator]'); const timeLimitContainer = page!
expect(stepIndicatorBefore).toContain('Time Limits'); .locator(IRACING_SELECTORS.wizard.stepContainers.timeLimit)
.first();
expect(await timeLimitContainer.count()).toBeGreaterThan(0);
const result = await harness.executeStep(7, { const result = await harness.executeStep(7, {
practice: 10, practice: 10,
qualify: 10, qualify: 10,
race: 20, race: 20,
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
const stepIndicatorAfter = await page!.textContent('[data-indicator]'); const raceSlider = page!
expect(stepIndicatorAfter).toContain('Set Cars'); .locator(IRACING_SELECTORS.steps.race)
.first();
const raceSliderExists = await raceSlider.count();
expect(raceSliderExists).toBeGreaterThan(0);
const raceValueAttr =
(await raceSlider.getAttribute('data-value')) ??
(await raceSlider.inputValue().catch(() => null));
expect(raceValueAttr).toBe('20');
const footerText = await page!.textContent('.wizard-footer');
expect(footerText).toMatch(/Cars/i);
}); });
}); });

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { StepHarness } from '../support/StepHarness'; import type { StepHarness } from '../support/StepHarness';
import { createStepHarness } from '../support/StepHarness'; import { createStepHarness } from '../support/StepHarness';
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
describe('Step 8 cars', () => { describe('Step 8 cars', () => {
let harness: StepHarness; let harness: StepHarness;
@@ -14,17 +15,25 @@ describe('Step 8 cars', () => {
}); });
describe('alignment', () => { describe('alignment', () => {
it('executes on Cars page in mock wizard', async () => { it('executes on Cars page in mock wizard and exposes Add Car UI', async () => {
await harness.navigateToFixtureStep(8); await harness.navigateToFixtureStep(8);
const page = harness.adapter.getPage(); const page = harness.adapter.getPage();
expect(page).not.toBeNull(); expect(page).not.toBeNull();
const stepIndicatorBefore = await page!.textContent('[data-indicator]'); const carsContainer = page!
expect(stepIndicatorBefore).toContain('Set Cars'); .locator(IRACING_SELECTORS.wizard.stepContainers.cars)
.first();
expect(await carsContainer.count()).toBeGreaterThan(0);
const addCarButton = page!
.locator(IRACING_SELECTORS.steps.addCarButton)
.first();
const addCarText = await addCarButton.innerText();
expect(addCarText.toLowerCase()).toContain('add a car');
const result = await harness.executeStep(8, {}); const result = await harness.executeStep(8, {});
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
}); });

View File

@@ -14,21 +14,28 @@ describe('Step 9 add car', () => {
}); });
describe('happy path', () => { describe('happy path', () => {
it('executes on Add Car modal from Cars step', async () => { it('adds a real car using the JSON-backed car list on Cars page', async () => {
await harness.navigateToFixtureStep(9); await harness.navigateToFixtureStep(8);
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
const page = harness.adapter.getPage(); const page = harness.adapter.getPage();
expect(page).not.toBeNull(); expect(page).not.toBeNull();
const modalTitleBefore = await page!.textContent('[data-indicator="add-car"]');
expect(modalTitleBefore).toContain('Add a Car');
const result = await harness.executeStep(9, { const result = await harness.executeStep(9, {
carSearch: 'Porsche 911 GT3 R', carSearch: 'Acura ARX-06',
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
const carsTable = page!
.locator('#select-car-set-cars table.table.table-striped')
.first();
expect(await carsTable.count()).toBeGreaterThan(0);
const acuraCell = carsTable.locator('tbody tr td >> text=Acura ARX-06 GTP');
expect(await acuraCell.count()).toBeGreaterThan(0);
}); });
}); });
@@ -52,7 +59,7 @@ describe('Step 9 add car', () => {
await harness.executeStep(9, { await harness.executeStep(9, {
carSearch: 'Porsche 911', carSearch: 'Porsche 911',
}); });
}).rejects.toThrow(/Expected cars step/i); }).rejects.toThrow(/Step 9 FAILED validation/i);
}); });
it('detects when Track container is present instead of Cars page', async () => { it('detects when Track container is present instead of Cars page', async () => {
@@ -63,7 +70,7 @@ describe('Step 9 add car', () => {
await harness.executeStep(9, { await harness.executeStep(9, {
carSearch: 'Ferrari 488', carSearch: 'Ferrari 488',
}); });
}).rejects.toThrow(/3 steps ahead|Track page/i); }).rejects.toThrow(/Step 9 FAILED validation/i);
}); });
it('passes validation when on Cars page', async () => { it('passes validation when on Cars page', async () => {
@@ -71,10 +78,22 @@ describe('Step 9 add car', () => {
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded'); await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
const result = await harness.executeStep(9, { const result = await harness.executeStep(9, {
carSearch: 'Mazda MX-5', carSearch: 'Acura ARX-06',
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
const page = harness.adapter.getPage();
expect(page).not.toBeNull();
const carsTable = page!
.locator('#select-car-set-cars table.table.table-striped')
.first();
expect(await carsTable.count()).toBeGreaterThan(0);
const acuraCell = carsTable.locator('tbody tr td >> text=Acura ARX-06 GTP');
expect(await acuraCell.count()).toBeGreaterThan(0);
}); });
it('provides detailed error context in validation failure', async () => { it('provides detailed error context in validation failure', async () => {

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { StepHarness } from '../support/StepHarness'; import type { StepHarness } from '../support/StepHarness';
import { createStepHarness } from '../support/StepHarness'; import { createStepHarness } from '../support/StepHarness';
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
describe('Step 14 time of day', () => { describe('Step 14 time of day', () => {
let harness: StepHarness; let harness: StepHarness;
@@ -13,22 +14,40 @@ describe('Step 14 time of day', () => {
await harness.dispose(); await harness.dispose();
}); });
it('executes on Time of Day page in mock wizard', async () => { it('executes on Time of Day page and applies time-of-day slider from config', async () => {
await harness.navigateToFixtureStep(14); await harness.navigateToFixtureStep(14);
const page = harness.adapter.getPage(); const page = harness.adapter.getPage();
expect(page).not.toBeNull(); expect(page).not.toBeNull();
const container = page!
.locator(IRACING_SELECTORS.wizard.stepContainers.timeOfDay)
.first();
expect(await container.count()).toBeGreaterThan(0);
const sidebarTimeOfDay = await page!.textContent( const sidebarTimeOfDay = await page!.textContent(
'#wizard-sidebar-link-set-time-of-day', '#wizard-sidebar-link-set-time-of-day',
); );
expect(sidebarTimeOfDay).toContain('Time of Day'); expect(sidebarTimeOfDay).toContain('Time of Day');
const result = await harness.executeStep(14, {}); const config = { timeOfDay: 800 };
const result = await harness.executeStep(14, config);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
const timeSlider = page!
.locator(IRACING_SELECTORS.steps.timeOfDay)
.first();
const sliderExists = await timeSlider.count();
expect(sliderExists).toBeGreaterThan(0);
const valueAttr =
(await timeSlider.getAttribute('data-value')) ??
(await timeSlider.inputValue().catch(() => null));
expect(valueAttr).toBe(String(config.timeOfDay));
const footerText = await page!.textContent('.wizard-footer'); const footerText = await page!.textContent('.wizard-footer');
expect(footerText).toMatch(/Weather/i); expect(footerText).toMatch(/Weather/i);
}); });

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { StepHarness } from '../support/StepHarness'; import type { StepHarness } from '../support/StepHarness';
import { createStepHarness } from '../support/StepHarness'; import { createStepHarness } from '../support/StepHarness';
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
describe('Step 15 weather', () => { describe('Step 15 weather', () => {
let harness: StepHarness; let harness: StepHarness;
@@ -13,7 +14,7 @@ describe('Step 15 weather', () => {
await harness.dispose(); await harness.dispose();
}); });
it('executes on Weather page in mock wizard', async () => { it('executes on Weather page in mock wizard and applies weather config from JSON-backed controls', async () => {
await harness.navigateToFixtureStep(15); await harness.navigateToFixtureStep(15);
const page = harness.adapter.getPage(); const page = harness.adapter.getPage();
@@ -27,9 +28,44 @@ describe('Step 15 weather', () => {
const bodyText = await page!.textContent('body'); const bodyText = await page!.textContent('body');
expect(bodyText).toMatch(/Weather Mode|Event weather/i); expect(bodyText).toMatch(/Weather Mode|Event weather/i);
const result = await harness.executeStep(15, { timeOfDay: 800 }); const config = {
weatherType: '2',
temperature: 650,
};
const result = await harness.executeStep(15, config);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
const weatherSelect = page!
.locator(IRACING_SELECTORS.steps.weatherType)
.first();
const weatherSelectCount = await weatherSelect.count();
if (weatherSelectCount > 0) {
const selectedWeatherValue =
(await weatherSelect.getAttribute('value')) ??
(await weatherSelect.textContent().catch(() => null));
expect(
(selectedWeatherValue ?? '').toLowerCase(),
).toMatch(/static|forecast|timeline|2/);
} else {
const radioGroup = page!.locator('[role="radiogroup"] input[type="radio"]').first();
const radioCount = await radioGroup.count();
expect(radioCount).toBeGreaterThan(0);
}
const tempSlider = page!
.locator(IRACING_SELECTORS.steps.temperature)
.first();
const tempExists = await tempSlider.count();
if (tempExists > 0) {
const tempValue =
(await tempSlider.getAttribute('data-value')) ??
(await tempSlider.inputValue().catch(() => null));
expect(tempValue).toBe(String(config.temperature));
}
}); });
}); });

View File

@@ -13,7 +13,7 @@ describe('Step 18 track conditions (manual stop)', () => {
await harness.dispose(); await harness.dispose();
}); });
it('does not automate Track Conditions and surfaces unknown-step result', async () => { it('treats Track Conditions as manual stop without invoking automation step 18', async () => {
await harness.navigateToFixtureStep(18); await harness.navigateToFixtureStep(18);
const page = harness.adapter.getPage(); const page = harness.adapter.getPage();
@@ -24,9 +24,10 @@ describe('Step 18 track conditions (manual stop)', () => {
); );
expect(sidebarTrackConditions).toContain('Track Conditions'); expect(sidebarTrackConditions).toContain('Track Conditions');
const result = await harness.executeStep(18, {}); const trackConditionsContainer = page!.locator('#set-track-conditions').first();
expect(await trackConditionsContainer.count()).toBeGreaterThan(0);
expect(result.success).toBe(false); const bodyText = await page!.textContent('body');
expect(result.error).toContain('Unknown step: 18'); expect(bodyText).toMatch(/Track Conditions|Starting Track State/i);
}); });
}); });

View File

@@ -28,9 +28,9 @@ export interface PermissionCheckResult {
/** /**
* PermissionGuard for E2E tests. * PermissionGuard for E2E tests.
* *
* Checks macOS Accessibility and Screen Recording permissions * Checks macOS Accessibility and Screen Recording permissions
* required for real nut.js automation. Provides graceful skip * required for real native automation. Provides graceful skip
* logic for CI environments or when permissions are unavailable. * logic for CI environments or when permissions are unavailable.
*/ */
export class PermissionGuard { export class PermissionGuard {
@@ -203,34 +203,17 @@ export class PermissionGuard {
/** /**
* Check macOS Screen Recording permission without Electron. * Check macOS Screen Recording permission without Electron.
* Uses CGPreflightScreenCaptureAccess (requires native code) or heuristics. * Uses `screencapture` heuristics to detect denial.
*/ */
private async checkMacOSScreenRecording(): Promise<boolean> { private async checkMacOSScreenRecording(): Promise<boolean> {
try { try {
// Use screencapture command with minimal output
// -c captures to clipboard, -x prevents sound
// This will succeed even without permission but we can check for errors
const { stderr } = await execAsync('screencapture -x -c 2>&1 || true'); const { stderr } = await execAsync('screencapture -x -c 2>&1 || true');
// If there's a permission error in stderr, we don't have permission
if (stderr.includes('permission') || stderr.includes('denied')) { if (stderr.includes('permission') || stderr.includes('denied')) {
return false; return false;
} }
// Additional check: try to use nut.js screen capture return true;
// This is the most reliable check but may throw
try {
const { screen } = await import('@nut-tree-fork/nut-js');
await screen.width();
return true;
} catch (nutError) {
const errorStr = String(nutError);
if (errorStr.includes('permission') || errorStr.includes('denied') || errorStr.includes('screen')) {
return false;
}
// Other errors might be unrelated to permissions
return true;
}
} catch { } catch {
return false; return false;
} }

View File

@@ -27,7 +27,6 @@ export async function createStepHarness(): Promise<StepHarness> {
headless: true, headless: true,
timeout: 5000, timeout: 5000,
mode: 'mock', mode: 'mock',
baseUrl: url,
}, },
logger, logger,
); );

View File

@@ -43,7 +43,7 @@ describe('Workflow hosted session end-to-end (fixture-backed)', () => {
return { repository, engine, useCase }; return { repository, engine, useCase };
} }
it('runs 118 from use case to STOPPED_AT_STEP_18', async () => { it('runs 117 from use case and stops automation at manual Track Conditions (STOPPED_AT_STEP_18)', async () => {
const { repository, engine, useCase } = createFixtureEngine(); const { repository, engine, useCase } = createFixtureEngine();
const config: any = { const config: any = {
@@ -64,13 +64,13 @@ describe('Workflow hosted session end-to-end (fixture-backed)', () => {
// Poll repository until automation loop completes // Poll repository until automation loop completes
// MockAutomationEngineAdapter drives the step orchestrator internally. // MockAutomationEngineAdapter drives the step orchestrator internally.
// Session should end in STOPPED_AT_STEP_18 when step 18 completes. // Session should end in STOPPED_AT_STEP_18 after completing automated step 17.
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
while (true) { while (true) {
const sessions = await repository.findAll(); const sessions = await repository.findAll();
finalSession = sessions[0] ?? null; finalSession = sessions[0] ?? null;
if (finalSession && (finalSession.state.isStoppedAtStep18() || finalSession.state.isFailed())) { if (finalSession && finalSession.state.isStoppedAtStep18()) {
break; break;
} }
@@ -83,7 +83,7 @@ describe('Workflow hosted session end-to-end (fixture-backed)', () => {
expect(finalSession).not.toBeNull(); expect(finalSession).not.toBeNull();
expect(finalSession!.state.isStoppedAtStep18()).toBe(true); expect(finalSession!.state.isStoppedAtStep18()).toBe(true);
expect(finalSession!.currentStep.value).toBe(18); expect(finalSession!.currentStep.value).toBe(17);
expect(finalSession!.startedAt).toBeInstanceOf(Date); expect(finalSession!.startedAt).toBeInstanceOf(Date);
expect(finalSession!.completedAt).toBeInstanceOf(Date); expect(finalSession!.completedAt).toBeInstanceOf(Date);
expect(finalSession!.errorMessage).toBeUndefined(); expect(finalSession!.errorMessage).toBeUndefined();
@@ -129,7 +129,7 @@ describe('Workflow hosted session end-to-end (fixture-backed)', () => {
const sessions = await repository.findAll(); const sessions = await repository.findAll();
finalSession = sessions[0] ?? null; finalSession = sessions[0] ?? null;
if (finalSession && (finalSession.state.isFailed() || finalSession.state.isStoppedAtStep18())) { if (finalSession && finalSession.state.isFailed()) {
break; break;
} }
@@ -143,11 +143,7 @@ describe('Workflow hosted session end-to-end (fixture-backed)', () => {
await failingAdapter.disconnect(); await failingAdapter.disconnect();
expect(finalSession).not.toBeNull(); expect(finalSession).not.toBeNull();
expect( expect(finalSession!.state.isFailed()).toBe(true);
finalSession!.state.isFailed() || finalSession!.state.isStoppedAtStep18(), expect(finalSession!.errorMessage).toBeDefined();
).toBe(true);
if (finalSession!.state.isFailed()) {
expect(finalSession!.errorMessage).toBeDefined();
}
}); });
}); });

View File

@@ -1,6 +1,10 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { PlaywrightAutomationAdapter, FixtureServer } from 'packages/infrastructure/adapters/automation'; import {
PlaywrightAutomationAdapter,
FixtureServer,
} from 'packages/infrastructure/adapters/automation';
import { StepId } from 'packages/domain/value-objects/StepId'; import { StepId } from 'packages/domain/value-objects/StepId';
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
describe('Workflow steps 79 cars flow (fixture-backed)', () => { describe('Workflow steps 79 cars flow (fixture-backed)', () => {
let adapter: PlaywrightAutomationAdapter; let adapter: PlaywrightAutomationAdapter;
@@ -18,7 +22,7 @@ describe('Workflow steps 79 cars flow (fixture-backed)', () => {
timeout: 5000, timeout: 5000,
baseUrl, baseUrl,
mode: 'mock', mode: 'mock',
} },
); );
await adapter.connect(); await adapter.connect();
}); });
@@ -28,7 +32,7 @@ describe('Workflow steps 79 cars flow (fixture-backed)', () => {
await server.stop(); await server.stop();
}); });
it('executes time limits, cars, and add car in sequence using fixtures', async () => { it('executes time limits, cars, and add car in sequence using fixtures and leaves JSON-backed state', async () => {
await adapter.navigateToPage(server.getFixtureUrl(7)); await adapter.navigateToPage(server.getFixtureUrl(7));
const step7Result = await adapter.executeStep(StepId.create(7), { const step7Result = await adapter.executeStep(StepId.create(7), {
practice: 10, practice: 10,
@@ -37,14 +41,43 @@ describe('Workflow steps 79 cars flow (fixture-backed)', () => {
}); });
expect(step7Result.success).toBe(true); expect(step7Result.success).toBe(true);
const page = adapter.getPage();
expect(page).not.toBeNull();
const raceSlider = page!
.locator(IRACING_SELECTORS.steps.race)
.first();
const raceSliderValue =
(await raceSlider.getAttribute('data-value')) ??
(await raceSlider.inputValue().catch(() => null));
expect(raceSliderValue).toBe('20');
await adapter.navigateToPage(server.getFixtureUrl(8)); await adapter.navigateToPage(server.getFixtureUrl(8));
const step8Result = await adapter.executeStep(StepId.create(8), {}); const step8Result = await adapter.executeStep(StepId.create(8), {});
expect(step8Result.success).toBe(true); expect(step8Result.success).toBe(true);
const carsContainer = page!
.locator(IRACING_SELECTORS.wizard.stepContainers.cars)
.first();
expect(await carsContainer.count()).toBeGreaterThan(0);
const addCarButton = page!
.locator(IRACING_SELECTORS.steps.addCarButton)
.first();
expect(await addCarButton.count()).toBeGreaterThan(0);
await adapter.navigateToPage(server.getFixtureUrl(9)); await adapter.navigateToPage(server.getFixtureUrl(9));
const step9Result = await adapter.executeStep(StepId.create(9), { const step9Result = await adapter.executeStep(StepId.create(9), {
carSearch: 'Porsche 911 GT3 R', carSearch: 'Acura ARX-06',
}); });
expect(step9Result.success).toBe(true); expect(step9Result.success).toBe(true);
const carsTable = page!
.locator('#select-car-set-cars table.table.table-striped')
.first();
expect(await carsTable.count()).toBeGreaterThan(0);
const acuraCell = carsTable.locator('tbody tr td >> text=Acura ARX-06 GTP');
expect(await acuraCell.count()).toBeGreaterThan(0);
}); });
}); });

View File

@@ -9,18 +9,19 @@ import * as path from 'path';
* and runtime configuration via BrowserModeConfigLoader. * and runtime configuration via BrowserModeConfigLoader.
*/ */
// Mock interfaces - will be replaced with actual imports in GREEN phase type BrowserModeSource = 'env' | 'file' | 'default';
interface PlaywrightAutomationAdapter {
interface PlaywrightAutomationAdapterLike {
connect(): Promise<{ success: boolean; error?: string }>; connect(): Promise<{ success: boolean; error?: string }>;
disconnect(): Promise<void>; disconnect(): Promise<void>;
isConnected(): boolean; isConnected(): boolean;
getBrowserMode(): 'headed' | 'headless'; getBrowserMode(): 'headed' | 'headless';
getBrowserModeSource(): 'GUI' | 'NODE_ENV'; getBrowserModeSource(): BrowserModeSource;
} }
describe('Browser Mode Integration - GREEN Phase', () => { describe('Browser Mode Integration - GREEN Phase', () => {
const originalEnv = process.env; const originalEnv = process.env;
let adapter: PlaywrightAutomationAdapter | null = null; let adapter: PlaywrightAutomationAdapterLike | null = null;
beforeEach(() => { beforeEach(() => {
process.env = { ...originalEnv }; process.env = { ...originalEnv };

View File

@@ -9,15 +9,8 @@ import { CheckoutStateEnum } from '../../../packages/domain/value-objects/Checko
* Tests verify HTML parsing for checkout price extraction and state detection. * Tests verify HTML parsing for checkout price extraction and state detection.
*/ */
interface Page { type Page = ConstructorParameters<typeof CheckoutPriceExtractor>[0];
locator(selector: string): Locator; type Locator = ReturnType<Page['locator']>;
}
interface Locator {
getAttribute(name: string): Promise<string | null>;
innerHTML(): Promise<string>;
textContent(): Promise<string | null>;
}
describe('CheckoutPriceExtractor Integration', () => { describe('CheckoutPriceExtractor Integration', () => {
let mockPage: Page; let mockPage: Page;

View File

@@ -31,7 +31,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
const state = CheckoutState.ready(); const state = CheckoutState.ready();
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue( vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
Result.ok({ price, state }) Result.ok({ price, state, buttonHtml: '' })
); );
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue( vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
@@ -60,7 +60,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
const state = CheckoutState.ready(); const state = CheckoutState.ready();
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue( vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
Result.ok({ price, state }) Result.ok({ price, state, buttonHtml: '' })
); );
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue( vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
@@ -80,7 +80,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
const state = CheckoutState.ready(); const state = CheckoutState.ready();
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue( vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
Result.ok({ price, state }) Result.ok({ price, state, buttonHtml: '' })
); );
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue( vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
@@ -100,7 +100,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
const state = CheckoutState.ready(); const state = CheckoutState.ready();
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue( vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
Result.ok({ price, state }) Result.ok({ price, state, buttonHtml: '' })
); );
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue( vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
@@ -118,7 +118,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
const state = CheckoutState.insufficientFunds(); const state = CheckoutState.insufficientFunds();
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue( vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
Result.ok({ price, state }) Result.ok({ price, state, buttonHtml: '' })
); );
const result = await useCase.execute(); const result = await useCase.execute();
@@ -139,7 +139,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
}; };
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue( vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
Result.ok({ price, state }) Result.ok({ price, state, buttonHtml: '' })
); );
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue( vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
import { StartAutomationSessionUseCase } from '../../../../packages/application/use-cases/StartAutomationSessionUseCase'; import { StartAutomationSessionUseCase } from '../../../../packages/application/use-cases/StartAutomationSessionUseCase';
import { IAutomationEngine } from '../../../../packages/application/ports/IAutomationEngine'; import { IAutomationEngine } from '../../../../packages/application/ports/IAutomationEngine';
import { IBrowserAutomation } from '../../../../packages/application/ports/IBrowserAutomation'; import { IScreenAutomation } from '../../../../packages/application/ports/IScreenAutomation';
import { ISessionRepository } from '../../../../packages/application/ports/ISessionRepository'; import { ISessionRepository } from '../../../../packages/application/ports/ISessionRepository';
import { AutomationSession } from '../../../../packages/domain/entities/AutomationSession'; import { AutomationSession } from '../../../../packages/domain/entities/AutomationSession';
@@ -48,7 +48,7 @@ describe('StartAutomationSessionUseCase', () => {
useCase = new StartAutomationSessionUseCase( useCase = new StartAutomationSessionUseCase(
mockAutomationEngine as unknown as IAutomationEngine, mockAutomationEngine as unknown as IAutomationEngine,
mockBrowserAutomation as unknown as IBrowserAutomation, mockBrowserAutomation as unknown as IScreenAutomation,
mockSessionRepository as unknown as ISessionRepository mockSessionRepository as unknown as ISessionRepository
); );
}); });

View File

@@ -60,7 +60,7 @@ describe('SessionCookieStore - Cookie Validation', () => {
const result = cookieStore.validateCookieConfiguration(targetUrl); const result = cookieStore.validateCookieConfiguration(targetUrl);
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toMatch(/domain mismatch/i); expect(result.unwrapErr().message).toMatch(/domain mismatch/i);
}); });
test('should fail when cookie path is invalid for target', async () => { test('should fail when cookie path is invalid for target', async () => {
@@ -81,7 +81,7 @@ describe('SessionCookieStore - Cookie Validation', () => {
const result = cookieStore.validateCookieConfiguration(targetUrl); const result = cookieStore.validateCookieConfiguration(targetUrl);
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toMatch(/path.*not valid/i); expect(result.unwrapErr().message).toMatch(/path.*not valid/i);
}); });
test('should fail when required irsso_members cookie is missing', async () => { test('should fail when required irsso_members cookie is missing', async () => {
@@ -102,7 +102,7 @@ describe('SessionCookieStore - Cookie Validation', () => {
const result = cookieStore.validateCookieConfiguration(targetUrl); const result = cookieStore.validateCookieConfiguration(targetUrl);
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toMatch(/required.*irsso_members/i); expect(result.unwrapErr().message).toMatch(/required.*irsso_members/i);
}); });
test('should fail when required authtoken_members cookie is missing', async () => { test('should fail when required authtoken_members cookie is missing', async () => {
@@ -123,14 +123,14 @@ describe('SessionCookieStore - Cookie Validation', () => {
const result = cookieStore.validateCookieConfiguration(targetUrl); const result = cookieStore.validateCookieConfiguration(targetUrl);
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toMatch(/required.*authtoken_members/i); expect(result.unwrapErr().message).toMatch(/required.*authtoken_members/i);
}); });
test('should fail when no cookies are stored', () => { test('should fail when no cookies are stored', () => {
const result = cookieStore.validateCookieConfiguration(targetUrl); const result = cookieStore.validateCookieConfiguration(targetUrl);
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toMatch(/no cookies/i); expect(result.unwrapErr().message).toMatch(/no cookies/i);
}); });
test('should validate cookies for members-ng.iracing.com domain', async () => { test('should validate cookies for members-ng.iracing.com domain', async () => {

View File

@@ -2,7 +2,7 @@
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "ESNext", "module": "ESNext",
"lib": ["ES2022"], "lib": ["ES2022", "DOM"],
"moduleResolution": "node", "moduleResolution": "node",
"esModuleInterop": true, "esModuleInterop": true,
"strict": true, "strict": true,
@@ -17,7 +17,8 @@
"packages/*": ["packages/*"], "packages/*": ["packages/*"],
"apps/*": ["apps/*"] "apps/*": ["apps/*"]
}, },
"types": ["vitest/globals"] "types": ["vitest/globals", "node"],
"jsx": "react-jsx"
}, },
"include": [ "include": [
"packages/**/*", "packages/**/*",
@@ -27,6 +28,7 @@
"exclude": [ "exclude": [
"node_modules", "node_modules",
"dist", "dist",
"**/*.js" "**/*.js",
"tests/e2e/step-definitions/automation.steps.ts"
] ]
} }

View File

@@ -1,16 +1,19 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
/** /**
* E2E Test Configuration * E2E Test Configuration
* *
* IMPORTANT: E2E tests run against real OS automation. * IMPORTANT: E2E tests run against real OS automation.
* This configuration includes strict timeouts to prevent hanging. * This configuration includes strict timeouts to prevent hanging.
*/ */
const RUN_REAL_AUTOMATION_SMOKE = process.env.RUN_REAL_AUTOMATION_SMOKE === '1';
export default defineConfig({ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: 'node', environment: 'node',
include: ['tests/e2e/**/*.e2e.test.ts'], include: ['tests/e2e/**/*.e2e.test.ts'],
exclude: RUN_REAL_AUTOMATION_SMOKE ? [] : ['tests/e2e/automation.e2e.test.ts'],
// E2E tests use real automation - set strict timeouts to prevent hanging // E2E tests use real automation - set strict timeouts to prevent hanging
// Individual tests: 30 seconds max // Individual tests: 30 seconds max
testTimeout: 30000, testTimeout: 30000,