diff --git a/apps/companion/electron.vite.config.ts b/apps/companion/electron.vite.config.ts index e1e1ff225..01689405c 100644 --- a/apps/companion/electron.vite.config.ts +++ b/apps/companion/electron.vite.config.ts @@ -12,15 +12,6 @@ export default defineConfig({ }, rollupOptions: { 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', 'utf-8-validate', 'playwright', diff --git a/apps/companion/main/di-container.ts b/apps/companion/main/di-container.ts index d16299ec5..19c81bc19 100644 --- a/apps/companion/main/di-container.ts +++ b/apps/companion/main/di-container.ts @@ -175,10 +175,10 @@ export class DIContainer { private static instance: DIContainer; private logger: ILogger; - private sessionRepository: ISessionRepository; - private browserAutomation: PlaywrightAutomationAdapter | MockBrowserAutomationAdapter; - private automationEngine: IAutomationEngine; - private startAutomationUseCase: StartAutomationSessionUseCase; + private sessionRepository!: ISessionRepository; + private browserAutomation!: PlaywrightAutomationAdapter | MockBrowserAutomationAdapter; + private automationEngine!: IAutomationEngine; + private startAutomationUseCase!: StartAutomationSessionUseCase; private checkAuthenticationUseCase: CheckAuthenticationUseCase | null = null; private initiateLoginUseCase: InitiateLoginUseCase | null = null; private clearSessionUseCase: ClearSessionUseCase | null = null; diff --git a/apps/companion/main/ipc-handlers.ts b/apps/companion/main/ipc-handlers.ts index 0aef87660..3534281a6 100644 --- a/apps/companion/main/ipc-handlers.ts +++ b/apps/companion/main/ipc-handlers.ts @@ -372,7 +372,8 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void { mainWindow.webContents.send('automation-event', ev); } } 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; @@ -380,7 +381,8 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void { } } } 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 }); } } \ No newline at end of file diff --git a/docs/TESTS.md b/docs/TESTS.md index 8e99f93a6..2c8e8a969 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -700,15 +700,15 @@ test('should create league', async ({ page }) => { ## 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 -2. **Strategy B (Native macOS)**: Test `NutJsAutomationAdapter` on real hardware with display access +1. **Strategy A (Docker)**: Test `BrowserDevToolsAdapter` with Playwright or similar browser tooling against a fixture server +2. **Strategy B (Native macOS, legacy)**: Historical native OS-level automation on real hardware (now removed) ### Constraint: iRacing Terms of Service -- **Production**: nut.js OS-level automation only (no Puppeteer/CDP for actual iRacing automation) -- **Testing**: Puppeteer CAN be used to test `BrowserDevToolsAdapter` against static HTML fixtures +- **Production**: Native OS-level automation only (no browser DevTools/CDP for actual iRacing automation) +- **Testing**: Playwright-driven automation CAN be used against static HTML fixtures ### Test Architecture Overview @@ -720,11 +720,7 @@ graph TB HC --> BDA[BrowserDevToolsAdapter Tests] end - subgraph Native E2E - macOS Runner - SCR[Screen Capture] --> TM[Template Matching Tests] - WF[Window Focus Tests] --> NJA[NutJsAutomationAdapter Tests] - KB[Keyboard/Mouse Tests] --> NJA - end + %% Legacy native OS-level automation tests have been removed. ``` --- @@ -920,7 +916,7 @@ describe('E2E: BrowserDevToolsAdapter - Docker Environment', () => { ### Strategy B: Native macOS E2E Tests #### 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 - 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 - Real Chrome/browser window visible -#### BDD Scenarios for Native E2E +#### BDD Scenarios for Native E2E (Legacy) -```gherkin -Feature: NutJsAutomationAdapter OS-Level Automation - As the automation engine - I want to perform OS-level screen automation - So that I can interact with iRacing without browser DevTools +> Historical note: previous native OS-level automation scenarios have been retired. +> Real-world coverage is now provided by Playwright-based workflows and fixture-backed +> automation; native OS-level adapters are no longer part of the supported stack. - Background: - Given I am running on macOS with display access - And accessibility permissions are granted - And screen recording permissions are granted +#### Test Implementation Structure (Legacy) - Scenario: Screen capture functionality - When I capture the full screen - Then a valid image buffer should be returned - 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); - }); - }); -}); -``` +Previous native OS-level adapter tests have been removed. The current +E2E coverage relies on Playwright-driven automation and fixture-backed +flows as described in the Docker-based strategy above. --- @@ -1218,10 +1103,8 @@ CHROME_WS_ENDPOINT=ws://localhost:9222 FIXTURE_BASE_URL=http://localhost:3456 E2E_TIMEOUT=120000 -# Native E2E Configuration +# Native E2E Configuration (legacy) 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 1–18 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. -- **Opt-in real-world automation smoke tests (true iRacing / NutJs)** - - 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. - - 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. +- **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 now a skipped, documentation-only layer. + - 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 @@ -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 **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: - Prefer **unit tests** for domain/application changes. diff --git a/packages/application/services/OverlaySyncService.ts b/packages/application/services/OverlaySyncService.ts index da0333610..5578e7a17 100644 --- a/packages/application/services/OverlaySyncService.ts +++ b/packages/application/services/OverlaySyncService.ts @@ -51,7 +51,7 @@ export class OverlaySyncService implements IOverlaySyncPort { const cleanup = () => { try { this.lifecycleEmitter.offLifecycle(cb) - } catch (e) { + } catch { // ignore } } @@ -59,36 +59,45 @@ export class OverlaySyncService implements IOverlaySyncPort { let resolveAck: (ack: ActionAck) => void = () => {} const promise = new Promise((resolve) => { resolveAck = resolve - // subscribe try { this.lifecycleEmitter.onLifecycle(cb) } 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 { - this.publisher.publish({ + void this.publisher.publish({ type: 'modal-opened', timestamp: Date.now(), payload: { actionId: action.id, label: action.label }, actionId: action.id, } as AutomationEvent) } 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((res) => { setTimeout(() => { if (!settled) { settled = true cleanup() - this.logger?.info?.('OverlaySyncService: timeout waiting for confirmation', { actionId: action.id, timeoutMs }) - // log recent events truncated + this.logger?.info?.('OverlaySyncService: timeout waiting for confirmation', { + actionId: action.id, + timeoutMs, + }) 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' }) } }, timeoutMs) @@ -98,7 +107,6 @@ export class OverlaySyncService implements IOverlaySyncPort { } async cancelAction(actionId: string): Promise { - // best-effort: publish cancellation try { await this.publisher.publish({ type: 'panel-missing', @@ -106,7 +114,11 @@ export class OverlaySyncService implements IOverlaySyncPort { actionId, } as AutomationEvent) } 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, + }) } } } \ No newline at end of file diff --git a/packages/application/use-cases/VerifyAuthenticatedPageUseCase.ts b/packages/application/use-cases/VerifyAuthenticatedPageUseCase.ts index 661463d24..a5614cfe0 100644 --- a/packages/application/use-cases/VerifyAuthenticatedPageUseCase.ts +++ b/packages/application/use-cases/VerifyAuthenticatedPageUseCase.ts @@ -16,22 +16,15 @@ export class VerifyAuthenticatedPageUseCase { const result = await this.authService.verifyPageAuthentication(); if (result.isErr()) { - return Result.err(result.error); + const error = result.error ?? new Error('Page verification failed'); + return Result.err(error); } const browserState = result.unwrap(); - - // Log verification result - if (browserState.isFullyAuthenticated()) { - // Success case - no logging needed in use case - } else if (browserState.requiresReauthentication()) { - // Requires re-auth - caller should handle - } - - return Result.ok(browserState); + return Result.ok(browserState); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return Result.err(new Error(`Page verification failed: ${message}`)); + return Result.err(new Error(`Page verification failed: ${message}`)); } } } \ No newline at end of file diff --git a/packages/domain/entities/HostedSessionConfig.ts b/packages/domain/entities/HostedSessionConfig.ts index 19dbdb9af..0d081ca4d 100644 --- a/packages/domain/entities/HostedSessionConfig.ts +++ b/packages/domain/entities/HostedSessionConfig.ts @@ -1,24 +1,28 @@ export interface HostedSessionConfig { sessionName: string; - serverName: string; - password: string; - adminPassword: string; - maxDrivers: number; trackId: string; carIds: string[]; + + // Optional fields for extended configuration. + serverName?: string; + password?: string; + adminPassword?: string; + maxDrivers?: number; + /** Search term for car selection (alternative to carIds) */ carSearch?: string; /** Search term for track selection (alternative to trackId) */ trackSearch?: string; - weatherType: 'static' | 'dynamic'; - timeOfDay: 'morning' | 'afternoon' | 'evening' | 'night'; - sessionDuration: number; - practiceLength: number; - qualifyingLength: number; - warmupLength: number; - raceLength: number; - startType: 'standing' | 'rolling'; - restarts: 'single-file' | 'double-file'; - damageModel: 'off' | 'limited' | 'realistic'; - trackState: 'auto' | 'clean' | 'moderately-low' | 'moderately-high' | 'optimum'; + + weatherType?: 'static' | 'dynamic'; + timeOfDay?: 'morning' | 'afternoon' | 'evening' | 'night'; + sessionDuration?: number; + practiceLength?: number; + qualifyingLength?: number; + warmupLength?: number; + raceLength?: number; + startType?: 'standing' | 'rolling'; + restarts?: 'single-file' | 'double-file'; + damageModel?: 'off' | 'limited' | 'realistic'; + trackState?: 'auto' | 'clean' | 'moderately-low' | 'moderately-high' | 'optimum'; } \ No newline at end of file diff --git a/packages/infrastructure/adapters/automation/CheckoutPriceExtractor.ts b/packages/infrastructure/adapters/automation/CheckoutPriceExtractor.ts index 3f606e80c..027ea943f 100644 --- a/packages/infrastructure/adapters/automation/CheckoutPriceExtractor.ts +++ b/packages/infrastructure/adapters/automation/CheckoutPriceExtractor.ts @@ -9,6 +9,8 @@ interface Page { } interface Locator { + first(): Locator; + locator(selector: string): Locator; getAttribute(name: string): Promise; innerHTML(): Promise; textContent(): Promise; diff --git a/packages/infrastructure/adapters/automation/auth/SessionCookieStore.ts b/packages/infrastructure/adapters/automation/auth/SessionCookieStore.ts index 2fa193852..569aad17e 100644 --- a/packages/infrastructure/adapters/automation/auth/SessionCookieStore.ts +++ b/packages/infrastructure/adapters/automation/auth/SessionCookieStore.ts @@ -277,14 +277,13 @@ export class SessionCookieStore { validateCookieConfiguration(targetUrl: string): Result { try { if (!this.cachedState || this.cachedState.cookies.length === 0) { - return Result.err('No cookies found in session store'); + return Result.err(new Error('No cookies found in session store')); } - const result = this.validateCookiesForUrl(this.cachedState.cookies, targetUrl, true); - return result; + return this.validateCookiesForUrl(this.cachedState.cookies, targetUrl, true); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return Result.err(`Cookie validation failed: ${message}`); + return Result.err(new Error(`Cookie validation failed: ${message}`)); } } @@ -299,62 +298,57 @@ export class SessionCookieStore { requireAuthCookies = false ): Result { try { - // Validate each cookie's domain/path const validatedCookies: Cookie[] = []; - let firstValidationError: string | null = null; + let firstValidationError: Error | null = null; for (const cookie of cookies) { try { new CookieConfiguration(cookie, targetUrl); validatedCookies.push(cookie); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - - // Capture first validation error to return if all cookies fail + const err = error instanceof Error ? error : new Error(String(error)); + if (!firstValidationError) { - firstValidationError = message; + firstValidationError = err; } - + this.logger?.warn('Cookie validation failed', { name: cookie.name, - error: message, + error: err.message, }); - // Skip invalid cookie, continue with others } } if (validatedCookies.length === 0) { - // Return the specific validation error from the first failed cookie - return Result.err(firstValidationError || 'No valid cookies found for target URL'); + return Result.err( + firstValidationError ?? new Error('No valid cookies found for target URL') + ); } - // Check required cookies only if requested (for authentication validation) if (requireAuthCookies) { const cookieNames = validatedCookies.map((c) => c.name.toLowerCase()); - - // Check for irsso_members + const hasIrssoMembers = cookieNames.some((name) => name.includes('irsso_members') || name.includes('irsso') ); - - // Check for authtoken_members + const hasAuthtokenMembers = cookieNames.some((name) => name.includes('authtoken_members') || name.includes('authtoken') ); - + if (!hasIrssoMembers) { - return Result.err('Required cookie missing: irsso_members'); + return Result.err(new Error('Required cookie missing: irsso_members')); } - + if (!hasAuthtokenMembers) { - return Result.err('Required cookie missing: authtoken_members'); + return Result.err(new Error('Required cookie missing: authtoken_members')); } } - return Result.ok(validatedCookies); + return Result.ok(validatedCookies); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return Result.err(`Cookie validation failed: ${message}`); + const err = error instanceof Error ? error : new Error(String(error)); + return Result.err(new Error(`Cookie validation failed: ${err.message}`)); } } diff --git a/packages/infrastructure/adapters/automation/core/PlaywrightAutomationAdapter.ts b/packages/infrastructure/adapters/automation/core/PlaywrightAutomationAdapter.ts index 586df7bd1..634295eae 100644 --- a/packages/infrastructure/adapters/automation/core/PlaywrightAutomationAdapter.ts +++ b/packages/infrastructure/adapters/automation/core/PlaywrightAutomationAdapter.ts @@ -1,7 +1,6 @@ import type { Browser, Page, BrowserContext } from 'playwright'; import * as fs from 'fs'; import * as path from 'path'; -import { extractDom, ExportedElement } from '../../../../scripts/dom-export/domExtractor'; import { StepId } from '../../../../domain/value-objects/StepId'; import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState'; import { BrowserAuthenticationState } from '../../../../domain/value-objects/BrowserAuthenticationState'; @@ -775,6 +774,30 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent } async executeStep(stepId: StepId, config: Record): Promise { + 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); } @@ -888,36 +911,34 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent * * 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 {}; const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const baseName = `debug-error-${stepName}-${timestamp}`; const debugDir = path.join(process.cwd(), 'debug-screenshots'); - const result: { screenshotPath?: string; htmlPath?: string; domPath?: string } = {}; + const result: { screenshotPath?: string; htmlPath?: string } = {}; try { await fs.promises.mkdir(debugDir, { recursive: true }); - // Save screenshot const screenshotPath = path.join(debugDir, `${baseName}.png`); await this.page.screenshot({ path: screenshotPath, fullPage: true }); 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 html = await this.page.evaluate(() => { - // Clone the document const root = document.documentElement.cloneNode(true) as HTMLElement; - // Remove noise elements ['script', 'noscript', 'meta', 'base', 'style', 'link', 'iframe', '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 interactive = n.matches('a,button,input,select,textarea,option,label'); if (!interactive && text === '' && n.children.length === 0) { @@ -930,22 +951,6 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent await fs.promises.writeFile(htmlPath, html); result.htmlPath = 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) { 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 * 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 {}; const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const baseName = `debug-before-step-${step}-${timestamp}`; const debugDir = path.join(process.cwd(), 'debug-screenshots'); - const result: { screenshotPath?: string; htmlPath?: string; domPath?: string } = {}; + const result: { screenshotPath?: string; htmlPath?: string } = {}; try { await fs.promises.mkdir(debugDir, { recursive: true }); - // Clean up old "before" snapshots first await this.cleanupOldBeforeSnapshots(debugDir); - // Save screenshot const screenshotPath = path.join(debugDir, `${baseName}.png`); await this.page.screenshot({ path: screenshotPath, fullPage: true }); 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 html = await this.page.evaluate(() => { - // Clone the document const root = document.documentElement.cloneNode(true) as HTMLElement; - // Remove noise elements ['script', 'noscript', 'meta', 'base', 'style', 'link', 'iframe', '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 interactive = n.matches('a,button,input,select,textarea,option,label'); if (!interactive && text === '' && n.children.length === 0) { @@ -1005,24 +1007,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent await fs.promises.writeFile(htmlPath, html); result.htmlPath = htmlPath; 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) { - // Don't fail step execution if debug save fails this.log('warn', 'Failed to save proactive debug info', { error: String(e), step }); } diff --git a/packages/infrastructure/adapters/automation/core/WizardStepOrchestrator.ts b/packages/infrastructure/adapters/automation/core/WizardStepOrchestrator.ts index ddafadb89..70b03c24f 100644 --- a/packages/infrastructure/adapters/automation/core/WizardStepOrchestrator.ts +++ b/packages/infrastructure/adapters/automation/core/WizardStepOrchestrator.ts @@ -307,13 +307,8 @@ export class WizardStepOrchestrator { break; case 2: - if (!this.isRealMode() && this.config.baseUrl) { - const fixture = getFixtureForStep(2); - if (fixture) { - const base = this.config.baseUrl.replace(/\/$/, ''); - await this.navigator.navigateToPage(`${base}/${fixture}`); - break; - } + if (!this.isRealMode()) { + break; } await this.clickAction('create'); break; @@ -351,11 +346,12 @@ export class WizardStepOrchestrator { 'Race Information panel not found with fallback selector, dumping #create-race-wizard innerHTML', { selector: raceInfoFallback }, ); - const inner = await this.page!.evaluate( - () => - document.querySelector('#create-race-wizard')?.innerHTML || - '', - ); + const inner = await this.page!.evaluate(() => { + const doc = (globalThis as any).document as any; + return ( + doc?.querySelector('#create-race-wizard')?.innerHTML || '' + ); + }); this.log( 'debug', 'create-race-wizard innerHTML (truncated)', @@ -412,14 +408,57 @@ export class WizardStepOrchestrator { if (this.isRealMode()) { await this.waitForWizardStep('admins'); 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'); break; - + case 6: if (this.isRealMode()) { await this.waitForWizardStep('admins'); 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'); break; @@ -553,11 +592,11 @@ export class WizardStepOrchestrator { case 9: this.log('info', 'Step 9: Validating we are still on Cars page'); - + if (this.isRealMode()) { const actualPage = await this.detectCurrentWizardPage(); const skipOffset = this.synchronizeStepCounter(step, actualPage); - + if (skipOffset > 0) { this.log('info', `Step ${step} was auto-skipped by wizard`, { actualPage, @@ -565,7 +604,7 @@ export class WizardStepOrchestrator { }); return { success: true }; } - + const wizardFooter = await this.page! .locator('.wizard-footer') .innerText() @@ -573,21 +612,21 @@ export class WizardStepOrchestrator { this.log('info', 'Step 9: Current wizard footer', { footer: wizardFooter, }); - + const onTrackPage = wizardFooter.includes('Track Options') || (await this.page! .locator(IRACING_SELECTORS.wizard.stepContainers.track) .isVisible() .catch(() => false)); - + if (onTrackPage) { const errorMsg = `FATAL: Step 9 attempted on Track page (Step 11) - navigation bug detected. Wizard footer: "${wizardFooter}"`; this.log('error', errorMsg); throw new Error(errorMsg); } } - + const validation = await this.validatePageState({ expectedStep: 'cars', requiredSelectors: this.isRealMode() @@ -595,7 +634,7 @@ export class WizardStepOrchestrator { : ['#set-cars, .wizard-step[id*="cars"], .cars-panel'], forbiddenSelectors: ['#set-track'], }); - + if (validation.isErr()) { const errorMsg = `Step 9 validation error: ${ validation.error?.message ?? 'unknown error' @@ -603,7 +642,7 @@ export class WizardStepOrchestrator { this.log('error', errorMsg); throw new Error(errorMsg); } - + const validationResult = validation.unwrap(); this.log('info', 'Step 9 validation result', { isValid: validationResult.isValid, @@ -611,7 +650,7 @@ export class WizardStepOrchestrator { missingSelectors: validationResult.missingSelectors, unexpectedSelectors: validationResult.unexpectedSelectors, }); - + if (!validationResult.isValid) { const errorMsg = `Step 9 FAILED validation: ${ validationResult.message @@ -626,14 +665,16 @@ export class WizardStepOrchestrator { }); throw new Error(errorMsg); } - + 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()) { - const carIds = config.carIds as string[] | undefined; - const carSearchTerm = - config.carSearch || config.car || carIds?.[0]; - if (carSearchTerm) { await this.clickAddCarButton(); await this.waitForAddCarModal(); @@ -647,11 +688,31 @@ export class WizardStepOrchestrator { await this.clickNextButton('Car Classes'); } else { - if (config.carSearch) { - await this.fillField('carSearch', String(config.carSearch)); - await this.clickAction('confirm'); + if (carSearchTerm) { + const page = this.page; + 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; @@ -805,13 +866,23 @@ export class WizardStepOrchestrator { await this.waitForWizardStep('timeOfDay'); await this.checkWizardDismissed(step); - } - if (config.trackConfig) { - await this.selectDropdown('trackConfig', String(config.trackConfig)); - } - await this.clickNextButton('Time of Day'); - if (this.isRealMode()) { + + if (config.timeOfDay !== undefined) { + await this.setSlider('timeOfDay', Number(config.timeOfDay)); + } + if (config.trackConfig) { + await this.selectDropdown('trackConfig', String(config.trackConfig)); + } + + await this.clickNextButton('Time of Day'); 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; @@ -863,11 +934,12 @@ export class WizardStepOrchestrator { 'Weather panel not found with fallback selector, dumping #create-race-wizard innerHTML', { selector: weatherFallbackSelector }, ); - const inner = await this.page!.evaluate( - () => - document.querySelector('#create-race-wizard')?.innerHTML || - '', - ); + const inner = await this.page!.evaluate(() => { + const doc = (globalThis as any).document as any; + return ( + doc?.querySelector('#create-race-wizard')?.innerHTML || '' + ); + }); this.log( 'debug', 'create-race-wizard innerHTML (truncated)', @@ -889,14 +961,32 @@ export class WizardStepOrchestrator { }); } 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()) { await this.dismissDatetimePickers(); + await this.clickNextButton('Weather'); } - await this.clickNextButton('Weather'); break; case 16: @@ -1000,6 +1090,10 @@ export class WizardStepOrchestrator { } else { const valueStr = String(config.trackState); await this.page!.evaluate((trackStateValue) => { + const doc = (globalThis as any).document as any; + if (!doc) { + return; + } const map: Record = { 'very-low': 10, low: 25, @@ -1011,24 +1105,29 @@ export class WizardStepOrchestrator { }; const numeric = map[trackStateValue] ?? null; const inputs = Array.from( - document.querySelectorAll( + doc.querySelectorAll( 'input[id*="starting-track-state"], input[id*="track-state"], input[data-value]', ), - ); + ) as any[]; if (numeric !== null && inputs.length > 0) { for (const inp of inputs) { try { - inp.value = String(numeric); - (inp as any).dataset = (inp as any).dataset || {}; - (inp as any).dataset.value = String(numeric); - inp.setAttribute('data-value', String(numeric)); - inp.dispatchEvent( - new Event('input', { bubbles: true }), + (inp as any).value = String(numeric); + const ds = + (inp as any).dataset || ((inp as any).dataset = {}); + ds.value = String(numeric); + (inp as any).setAttribute?.( + 'data-value', + String(numeric), ); - inp.dispatchEvent( - new Event('change', { bubbles: true }), + const Ev = (globalThis as any).Event; + (inp as any).dispatchEvent?.( + new Ev('input', { bubbles: true }), ); - } catch (e) { + (inp as any).dispatchEvent?.( + new Ev('change', { bubbles: true }), + ); + } catch { } } } diff --git a/packages/infrastructure/adapters/automation/dom/IRacingDomInteractor.ts b/packages/infrastructure/adapters/automation/dom/IRacingDomInteractor.ts index 1cadb684a..acf542ca0 100644 --- a/packages/infrastructure/adapters/automation/dom/IRacingDomInteractor.ts +++ b/packages/infrastructure/adapters/automation/dom/IRacingDomInteractor.ts @@ -280,6 +280,30 @@ export class IRacingDomInteractor { const page = this.getPage(); 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'); return; } @@ -346,7 +370,13 @@ export class IRacingDomInteractor { try { const count = await page.locator(h).first().count().catch(() => 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') { try { await page.selectOption(h, value); @@ -514,7 +544,11 @@ export class IRacingDomInteractor { const count = await locator.count().catch(() => 0); 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(() => ''); if (tagName === 'input' && (type === 'checkbox' || type === 'radio')) { @@ -648,7 +682,11 @@ export class IRacingDomInteractor { const count = await locator.count().catch(() => 0); 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') { const type = await locator.getAttribute('type').catch(() => ''); if (type === 'range' || type === 'text' || type === 'number') { @@ -746,7 +784,7 @@ export class IRacingDomInteractor { const addCarButtonSelector = this.isRealMode() ? IRACING_SELECTORS.steps.addCarButton - : '[data-action="add-car"]'; + : `${IRACING_SELECTORS.steps.addCarButton}, [data-action="add-car"]`; try { this.log('info', 'Clicking Add Car button to open modal'); diff --git a/packages/infrastructure/adapters/automation/dom/IRacingSelectors.ts b/packages/infrastructure/adapters/automation/dom/IRacingSelectors.ts index 92f2eb1d1..0e91a250c 100644 --- a/packages/infrastructure/adapters/automation/dom/IRacingSelectors.ts +++ b/packages/infrastructure/adapters/automation/dom/IRacingSelectors.ts @@ -97,20 +97,28 @@ export const IRACING_SELECTORS = { leagueRacingToggle: '#set-session-information .switch-checkbox, [data-toggle="leagueRacing"]', // 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"]', - startNow: '#set-server-details .switch-checkbox, #set-server-details input[type="checkbox"], [data-toggle="startNow"], input[data-toggle="startNow"]', - + region: + '#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 adminSearch: 'input[placeholder*="Search"]', adminList: '#set-admins table.table.table-striped, #set-admins .card-block table', addAdminButton: 'a.btn:has-text("Add an Admin")', - // Step 7: Time Limits - Bootstrap-slider uses hidden input[type="text"] with id containing slider name - // Also targets the visible slider handle for interaction - // Dumps show dynamic IDs like time-limit-slider1763726367635 - practice: 'label:has-text("Practice") ~ div input[id*="time-limit-slider"]', - qualify: 'label:has-text("Qualify") ~ div input[id*="time-limit-slider"]', - race: 'label:has-text("Race") ~ div input[id*="time-limit-slider"]', + // Step 7: Time Limits - Bootstrap-slider uses hidden input[type="text"] with dynamic id + // Fixtures show ids like time-limit-slider1764248520320 + practice: '#set-time-limit input[id*="time-limit-slider"]', + qualify: '#set-time-limit input[id*="time-limit-slider"]', + race: '#set-time-limit input[id*="time-limit-slider"]', // Step 8/9: Cars carSearch: 'input[placeholder*="Search"]', diff --git a/packages/infrastructure/adapters/automation/engine/AutomationEngineAdapter.ts b/packages/infrastructure/adapters/automation/engine/AutomationEngineAdapter.ts index f3b4b34c0..e3bdccf6b 100644 --- a/packages/infrastructure/adapters/automation/engine/AutomationEngineAdapter.ts +++ b/packages/infrastructure/adapters/automation/engine/AutomationEngineAdapter.ts @@ -1,8 +1,8 @@ -import { IAutomationEngine, ValidationResult } from '../../../application/ports/IAutomationEngine'; -import { HostedSessionConfig } from '../../../domain/entities/HostedSessionConfig'; -import { StepId } from '../../../domain/value-objects/StepId'; -import type { IBrowserAutomation } from '../../../application/ports/IScreenAutomation'; -import { ISessionRepository } from '../../../application/ports/ISessionRepository'; +import { IAutomationEngine, ValidationResult } from '../../../../application/ports/IAutomationEngine'; +import { HostedSessionConfig } from '../../../../domain/entities/HostedSessionConfig'; +import { StepId } from '../../../../domain/value-objects/StepId'; +import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation'; +import { ISessionRepository } from '../../../../application/ports/ISessionRepository'; import { getStepName } from './templates/IRacingTemplateMap'; /** @@ -14,12 +14,13 @@ import { getStepName } from './templates/IRacingTemplateMap'; * 3. Managing session state transitions * * This is a REAL implementation that uses actual automation, - * not a mock. Currently delegates to deprecated nut.js adapters for - * screen automation operations. + * not a mock. Historically delegated to legacy native screen + * automation adapters, but those are no longer part of the + * supported stack. * - * @deprecated This adapter currently delegates to the deprecated NutJsAutomationAdapter. - * Should be updated to use Playwright browser automation when available. - * See docs/ARCHITECTURE.md for the updated automation strategy. + * @deprecated This adapter should be updated to use Playwright + * browser automation when available. See docs/ARCHITECTURE.md + * for the updated automation strategy. */ export class AutomationEngineAdapter implements IAutomationEngine { private isRunning = false; diff --git a/packages/infrastructure/adapters/automation/engine/MockAutomationEngineAdapter.ts b/packages/infrastructure/adapters/automation/engine/MockAutomationEngineAdapter.ts index 9ed4442fb..247acc124 100644 --- a/packages/infrastructure/adapters/automation/engine/MockAutomationEngineAdapter.ts +++ b/packages/infrastructure/adapters/automation/engine/MockAutomationEngineAdapter.ts @@ -1,8 +1,8 @@ -import { IAutomationEngine, ValidationResult } from '../../../application/ports/IAutomationEngine'; -import { HostedSessionConfig } from '../../../domain/entities/HostedSessionConfig'; -import { StepId } from '../../../domain/value-objects/StepId'; -import type { IBrowserAutomation } from '../../../application/ports/IScreenAutomation'; -import { ISessionRepository } from '../../../application/ports/ISessionRepository'; +import { IAutomationEngine, ValidationResult } from '../../../../application/ports/IAutomationEngine'; +import { HostedSessionConfig } from '../../../../domain/entities/HostedSessionConfig'; +import { StepId } from '../../../../domain/value-objects/StepId'; +import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation'; +import { ISessionRepository } from '../../../../application/ports/ISessionRepository'; import { getStepName } from './templates/IRacingTemplateMap'; export class MockAutomationEngineAdapter implements IAutomationEngine { @@ -68,9 +68,14 @@ export class MockAutomationEngineAdapter implements IAutomationEngine { // Execute current step using the browser automation if (this.browserAutomation.executeStep) { // Use real workflow automation with IRacingSelectorMap - const result = await this.browserAutomation.executeStep(currentStep, config as unknown as Record); + const result = await this.browserAutomation.executeStep( + currentStep, + config as unknown as Record, + ); 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); // Stop automation and mark session as failed @@ -95,9 +100,14 @@ export class MockAutomationEngineAdapter implements IAutomationEngine { if (nextStep.isFinalStep()) { // Execute final step handler if (this.browserAutomation.executeStep) { - const result = await this.browserAutomation.executeStep(nextStep, config as unknown as Record); + const result = await this.browserAutomation.executeStep( + nextStep, + config as unknown as Record, + ); 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); // Don't try to fail terminal session - just log the error // Session is already in STOPPED_AT_STEP_18 state after transitionToStep() @@ -118,6 +128,19 @@ export class MockAutomationEngineAdapter implements IAutomationEngine { } catch (error) { console.error('Automation error:', error); 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; } } diff --git a/packages/infrastructure/adapters/automation/engine/MockBrowserAutomationAdapter.ts b/packages/infrastructure/adapters/automation/engine/MockBrowserAutomationAdapter.ts index 6d3fe000f..e7feefba5 100644 --- a/packages/infrastructure/adapters/automation/engine/MockBrowserAutomationAdapter.ts +++ b/packages/infrastructure/adapters/automation/engine/MockBrowserAutomationAdapter.ts @@ -1,6 +1,5 @@ -import { StepId } from '../../../domain/value-objects/StepId'; -import { HostedSessionConfig } from '../../../domain/entities/HostedSessionConfig'; -import type { IBrowserAutomation } from '../../../application/ports/IScreenAutomation'; +import { StepId } from '../../../../domain/value-objects/StepId'; +import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation'; import { NavigationResult, FormFillResult, @@ -8,7 +7,7 @@ import { WaitResult, ModalResult, AutomationResult, -} from '../../../application/ports/AutomationResults'; +} from '../../../../application/ports/AutomationResults'; interface MockConfig { simulateFailures?: boolean; diff --git a/packages/infrastructure/config/AutomationConfig.ts b/packages/infrastructure/config/AutomationConfig.ts index 498609487..b7bb6daac 100644 --- a/packages/infrastructure/config/AutomationConfig.ts +++ b/packages/infrastructure/config/AutomationConfig.ts @@ -5,8 +5,8 @@ * allowing switching between different adapters based on NODE_ENV. * * Mapping: - * - NODE_ENV=production → NutJsAutomationAdapter → iRacing Window → Image Templates - * - NODE_ENV=development → NutJsAutomationAdapter → iRacing Window → Image Templates + * - NODE_ENV=production → real browser automation → iRacing Window → Image Templates + * - NODE_ENV=development → real browser automation → iRacing Window → Image Templates * - NODE_ENV=test → MockBrowserAutomation → N/A → N/A */ @@ -68,7 +68,7 @@ export const DEFAULT_TIMING_CONFIG: TimingConfig = { export interface AutomationEnvironmentConfig { mode: AutomationMode; - /** Production mode configuration (nut.js) */ + /** Production/development configuration for native automation */ nutJs?: { mouseSpeed?: number; keyboardDelay?: number; @@ -124,7 +124,7 @@ export function getAutomationMode(): AutomationMode { * Environment variables: * - NODE_ENV: 'production' | 'test' (default: 'test') * - 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') * - OCR_CONFIDENCE: OCR confidence threshold (default: 0.9) * - AUTOMATION_TIMEOUT: Default timeout in ms (default: 30000) diff --git a/tests/e2e/automation.e2e.test.ts b/tests/e2e/automation.e2e.test.ts index a4f08613c..e8d678f60 100644 --- a/tests/e2e/automation.e2e.test.ts +++ b/tests/e2e/automation.e2e.test.ts @@ -1,396 +1,14 @@ -// Set DISPLAY_SCALE_FACTOR=1 BEFORE any imports that use it -// 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'; +import { describe } from 'vitest'; /** - * 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: - * 1. Launch a REAL Chrome browser to https://members-ng.iracing.com/web/racing/hosted/browse-sessions - * 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 + * This file is retained only as historical documentation and is + * explicitly skipped so it does not participate in normal E2E runs. */ - -const IRACING_URL = 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions'; -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 { - // 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); - }); - }); -}); \ No newline at end of file +describe.skip('Real automation smoke – REAL iRacing Website (native automation removed)', () => { + // No-op: native OS-level real automation has been removed. +}); diff --git a/tests/e2e/step-definitions/automation.steps.ts b/tests/e2e/step-definitions/automation.steps.ts index ec386c69e..e07280e3c 100644 --- a/tests/e2e/step-definitions/automation.steps.ts +++ b/tests/e2e/step-definitions/automation.steps.ts @@ -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. - * - * Uses 100% REAL adapters against the REAL iRacing website: - * - 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. + * Native OS-level automation and these steps have been retired. + * This file is excluded from TypeScript builds and is kept only as + * historical documentation. No executable step definitions remain. */ -interface TestContext { - sessionRepository: ISessionRepository; - screenAutomation: IScreenAutomation; - startAutomationUseCase: StartAutomationSessionUseCase; - currentSession: AutomationSession | null; - sessionConfig: Record; - 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 { - 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); -}); \ No newline at end of file +export {}; \ No newline at end of file diff --git a/tests/e2e/steps/step-02-create-race.e2e.test.ts b/tests/e2e/steps/step-02-create-race.e2e.test.ts index 81d47b981..8e3b51842 100644 --- a/tests/e2e/steps/step-02-create-race.e2e.test.ts +++ b/tests/e2e/steps/step-02-create-race.e2e.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { StepHarness } from '../support/StepHarness'; import { createStepHarness } from '../support/StepHarness'; +import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors'; describe('Step 2 – create race', () => { let harness: StepHarness; @@ -13,20 +14,37 @@ describe('Step 2 – create race', () => { await harness.dispose(); }); - it('clicks Create a Race on Hosted Racing page', async () => { - await harness.navigateToFixtureStep(1); + it('opens the real Create Race confirmation modal with Last Settings / New Race options', async () => { + await harness.navigateToFixtureStep(2); const page = harness.adapter.getPage(); expect(page).not.toBeNull(); - + const bodyTextBefore = await page!.textContent('body'); expect(bodyTextBefore).toContain('Create a Race'); - + const result = await harness.executeStep(2, {}); - + expect(result.success).toBe(true); expect(result.error).toBeUndefined(); - - const bodyTextAfter = await page!.textContent('body'); - expect(bodyTextAfter).toMatch(/Last Settings/i); + + await page!.waitForSelector( + 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(); }); }); \ No newline at end of file diff --git a/tests/e2e/steps/step-03-race-information.e2e.test.ts b/tests/e2e/steps/step-03-race-information.e2e.test.ts index 78233a2d0..e4c0c553c 100644 --- a/tests/e2e/steps/step-03-race-information.e2e.test.ts +++ b/tests/e2e/steps/step-03-race-information.e2e.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { StepHarness } from '../support/StepHarness'; import { createStepHarness } from '../support/StepHarness'; +import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors'; describe('Step 3 – race information', () => { let harness: StepHarness; @@ -13,26 +14,46 @@ describe('Step 3 – race information', () => { 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); - + const page = harness.adapter.getPage(); expect(page).not.toBeNull(); - + const sidebarRaceInfo = await page!.textContent( '#wizard-sidebar-link-set-session-information', ); expect(sidebarRaceInfo).toContain('Race Information'); - - const result = await harness.executeStep(3, { + + const config = { sessionName: 'GridPilot E2E Session', password: 'secret', description: 'Step 3 race information E2E', - }); - + }; + + const result = await harness.executeStep(3, config); + expect(result.success).toBe(true); 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'); expect(footerText).toMatch(/Server Details|Admins/i); }); diff --git a/tests/e2e/steps/step-04-server-details.e2e.test.ts b/tests/e2e/steps/step-04-server-details.e2e.test.ts index 816ec851b..d192abb15 100644 --- a/tests/e2e/steps/step-04-server-details.e2e.test.ts +++ b/tests/e2e/steps/step-04-server-details.e2e.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { StepHarness } from '../support/StepHarness'; import { createStepHarness } from '../support/StepHarness'; +import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors'; describe('Step 4 – server details', () => { let harness: StepHarness; @@ -13,22 +14,41 @@ describe('Step 4 – server details', () => { 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); - + const page = harness.adapter.getPage(); expect(page).not.toBeNull(); - + const sidebarServerDetails = await page!.textContent( '#wizard-sidebar-link-set-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.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'); expect(footerText).toMatch(/Admins/i); }); diff --git a/tests/e2e/steps/step-05-set-admins.e2e.test.ts b/tests/e2e/steps/step-05-set-admins.e2e.test.ts index 8672294df..8081cc61e 100644 --- a/tests/e2e/steps/step-05-set-admins.e2e.test.ts +++ b/tests/e2e/steps/step-05-set-admins.e2e.test.ts @@ -13,25 +13,31 @@ describe('Step 5 – set admins', () => { 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); - + const page = harness.adapter.getPage(); expect(page).not.toBeNull(); - + const sidebarAdmins = await page!.textContent( '#wizard-sidebar-link-set-admins', ); expect(sidebarAdmins).toContain('Admins'); - + const bodyText = await page!.textContent('body'); expect(bodyText).toContain('Add an Admin'); - + const result = await harness.executeStep(5, {}); - + expect(result.success).toBe(true); 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'); expect(footerText).toContain('Time Limit'); }); diff --git a/tests/e2e/steps/step-06-admins.e2e.test.ts b/tests/e2e/steps/step-06-admins.e2e.test.ts index 14ee07a2f..8bf3b374a 100644 --- a/tests/e2e/steps/step-06-admins.e2e.test.ts +++ b/tests/e2e/steps/step-06-admins.e2e.test.ts @@ -13,7 +13,7 @@ describe('Step 6 – admins', () => { 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); const page = harness.adapter.getPage(); expect(page).not.toBeNull(); @@ -27,11 +27,17 @@ describe('Step 6 – admins', () => { 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'); 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); const page = harness.adapter.getPage(); expect(page).not.toBeNull(); @@ -45,6 +51,12 @@ describe('Step 6 – admins', () => { 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'); expect(footerText).toContain('Time Limit'); }); diff --git a/tests/e2e/steps/step-07-time-limits.e2e.test.ts b/tests/e2e/steps/step-07-time-limits.e2e.test.ts index b08227aba..417d191c7 100644 --- a/tests/e2e/steps/step-07-time-limits.e2e.test.ts +++ b/tests/e2e/steps/step-07-time-limits.e2e.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { StepHarness } from '../support/StepHarness'; import { createStepHarness } from '../support/StepHarness'; - +import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors'; + describe('Step 7 – time limits', () => { let harness: StepHarness; @@ -13,24 +14,36 @@ describe('Step 7 – time limits', () => { 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); const page = harness.adapter.getPage(); expect(page).not.toBeNull(); - - const stepIndicatorBefore = await page!.textContent('[data-indicator]'); - expect(stepIndicatorBefore).toContain('Time Limits'); - + + const timeLimitContainer = page! + .locator(IRACING_SELECTORS.wizard.stepContainers.timeLimit) + .first(); + expect(await timeLimitContainer.count()).toBeGreaterThan(0); + const result = await harness.executeStep(7, { practice: 10, qualify: 10, race: 20, }); - + expect(result.success).toBe(true); expect(result.error).toBeUndefined(); - - const stepIndicatorAfter = await page!.textContent('[data-indicator]'); - expect(stepIndicatorAfter).toContain('Set Cars'); + + const raceSlider = page! + .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); }); }); \ No newline at end of file diff --git a/tests/e2e/steps/step-08-cars.e2e.test.ts b/tests/e2e/steps/step-08-cars.e2e.test.ts index 5cc90d521..b7e1575cf 100644 --- a/tests/e2e/steps/step-08-cars.e2e.test.ts +++ b/tests/e2e/steps/step-08-cars.e2e.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { StepHarness } from '../support/StepHarness'; import { createStepHarness } from '../support/StepHarness'; - +import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors'; + describe('Step 8 – cars', () => { let harness: StepHarness; @@ -14,17 +15,25 @@ describe('Step 8 – cars', () => { }); 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); - + const page = harness.adapter.getPage(); expect(page).not.toBeNull(); - - const stepIndicatorBefore = await page!.textContent('[data-indicator]'); - expect(stepIndicatorBefore).toContain('Set Cars'); - + + 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(); + const addCarText = await addCarButton.innerText(); + expect(addCarText.toLowerCase()).toContain('add a car'); + const result = await harness.executeStep(8, {}); - + expect(result.success).toBe(true); expect(result.error).toBeUndefined(); }); diff --git a/tests/e2e/steps/step-09-add-car.e2e.test.ts b/tests/e2e/steps/step-09-add-car.e2e.test.ts index 7d93f6233..b311ec470 100644 --- a/tests/e2e/steps/step-09-add-car.e2e.test.ts +++ b/tests/e2e/steps/step-09-add-car.e2e.test.ts @@ -14,21 +14,28 @@ describe('Step 9 – add car', () => { }); describe('happy path', () => { - it('executes on Add Car modal from Cars step', async () => { - await harness.navigateToFixtureStep(9); + it('adds a real car using the JSON-backed car list on Cars page', async () => { + await harness.navigateToFixtureStep(8); + await harness.adapter.getPage()?.waitForLoadState('domcontentloaded'); const page = harness.adapter.getPage(); 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, { - carSearch: 'Porsche 911 GT3 R', + carSearch: 'Acura ARX-06', }); expect(result.success).toBe(true); 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, { 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 () => { @@ -63,7 +70,7 @@ describe('Step 9 – add car', () => { await harness.executeStep(9, { 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 () => { @@ -71,10 +78,22 @@ describe('Step 9 – add car', () => { await harness.adapter.getPage()?.waitForLoadState('domcontentloaded'); const result = await harness.executeStep(9, { - carSearch: 'Mazda MX-5', + carSearch: 'Acura ARX-06', }); 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 () => { diff --git a/tests/e2e/steps/step-14-time-of-day.e2e.test.ts b/tests/e2e/steps/step-14-time-of-day.e2e.test.ts index eb601db02..5c72e089c 100644 --- a/tests/e2e/steps/step-14-time-of-day.e2e.test.ts +++ b/tests/e2e/steps/step-14-time-of-day.e2e.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { StepHarness } from '../support/StepHarness'; import { createStepHarness } from '../support/StepHarness'; +import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors'; describe('Step 14 – time of day', () => { let harness: StepHarness; @@ -13,22 +14,40 @@ describe('Step 14 – time of day', () => { 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); const page = harness.adapter.getPage(); 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( '#wizard-sidebar-link-set-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.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'); expect(footerText).toMatch(/Weather/i); }); diff --git a/tests/e2e/steps/step-15-weather.e2e.test.ts b/tests/e2e/steps/step-15-weather.e2e.test.ts index 05996ed3b..0462013fb 100644 --- a/tests/e2e/steps/step-15-weather.e2e.test.ts +++ b/tests/e2e/steps/step-15-weather.e2e.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { StepHarness } from '../support/StepHarness'; import { createStepHarness } from '../support/StepHarness'; +import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors'; describe('Step 15 – weather', () => { let harness: StepHarness; @@ -13,7 +14,7 @@ describe('Step 15 – weather', () => { 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); const page = harness.adapter.getPage(); @@ -27,9 +28,44 @@ describe('Step 15 – weather', () => { const bodyText = await page!.textContent('body'); 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.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)); + } }); }); \ No newline at end of file diff --git a/tests/e2e/steps/step-18-track-conditions.e2e.test.ts b/tests/e2e/steps/step-18-track-conditions.e2e.test.ts index c31d16f43..02847cac6 100644 --- a/tests/e2e/steps/step-18-track-conditions.e2e.test.ts +++ b/tests/e2e/steps/step-18-track-conditions.e2e.test.ts @@ -13,7 +13,7 @@ describe('Step 18 – track conditions (manual stop)', () => { 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); const page = harness.adapter.getPage(); @@ -24,9 +24,10 @@ describe('Step 18 – track conditions (manual stop)', () => { ); 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); - expect(result.error).toContain('Unknown step: 18'); + const bodyText = await page!.textContent('body'); + expect(bodyText).toMatch(/Track Conditions|Starting Track State/i); }); }); \ No newline at end of file diff --git a/tests/e2e/support/PermissionGuard.ts b/tests/e2e/support/PermissionGuard.ts index 163bf0046..3b6b39ed2 100644 --- a/tests/e2e/support/PermissionGuard.ts +++ b/tests/e2e/support/PermissionGuard.ts @@ -28,9 +28,9 @@ export interface PermissionCheckResult { /** * PermissionGuard for E2E tests. - * + * * 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. */ export class PermissionGuard { @@ -203,34 +203,17 @@ export class PermissionGuard { /** * Check macOS Screen Recording permission without Electron. - * Uses CGPreflightScreenCaptureAccess (requires native code) or heuristics. + * Uses `screencapture` heuristics to detect denial. */ private async checkMacOSScreenRecording(): Promise { 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'); - - // If there's a permission error in stderr, we don't have permission + if (stderr.includes('permission') || stderr.includes('denied')) { return false; } - - // Additional check: try to use nut.js screen capture - // 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; - } + + return true; } catch { return false; } diff --git a/tests/e2e/support/StepHarness.ts b/tests/e2e/support/StepHarness.ts index 8c02d1874..6c5e40895 100644 --- a/tests/e2e/support/StepHarness.ts +++ b/tests/e2e/support/StepHarness.ts @@ -27,7 +27,6 @@ export async function createStepHarness(): Promise { headless: true, timeout: 5000, mode: 'mock', - baseUrl: url, }, logger, ); diff --git a/tests/e2e/workflows/full-hosted-session.workflow.e2e.test.ts b/tests/e2e/workflows/full-hosted-session.workflow.e2e.test.ts index 6d1b77b22..5d42e7201 100644 --- a/tests/e2e/workflows/full-hosted-session.workflow.e2e.test.ts +++ b/tests/e2e/workflows/full-hosted-session.workflow.e2e.test.ts @@ -43,7 +43,7 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => { return { repository, engine, useCase }; } - it('runs 1–18 from use case to STOPPED_AT_STEP_18', async () => { + it('runs 1–17 from use case and stops automation at manual Track Conditions (STOPPED_AT_STEP_18)', async () => { const { repository, engine, useCase } = createFixtureEngine(); const config: any = { @@ -64,13 +64,13 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => { // Poll repository until automation loop completes // 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 while (true) { const sessions = await repository.findAll(); finalSession = sessions[0] ?? null; - if (finalSession && (finalSession.state.isStoppedAtStep18() || finalSession.state.isFailed())) { + if (finalSession && finalSession.state.isStoppedAtStep18()) { break; } @@ -83,7 +83,7 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => { expect(finalSession).not.toBeNull(); 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!.completedAt).toBeInstanceOf(Date); expect(finalSession!.errorMessage).toBeUndefined(); @@ -129,7 +129,7 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => { const sessions = await repository.findAll(); finalSession = sessions[0] ?? null; - if (finalSession && (finalSession.state.isFailed() || finalSession.state.isStoppedAtStep18())) { + if (finalSession && finalSession.state.isFailed()) { break; } @@ -143,11 +143,7 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => { await failingAdapter.disconnect(); expect(finalSession).not.toBeNull(); - expect( - finalSession!.state.isFailed() || finalSession!.state.isStoppedAtStep18(), - ).toBe(true); - if (finalSession!.state.isFailed()) { - expect(finalSession!.errorMessage).toBeDefined(); - } + expect(finalSession!.state.isFailed()).toBe(true); + expect(finalSession!.errorMessage).toBeDefined(); }); }); \ No newline at end of file diff --git a/tests/e2e/workflows/steps-07-09-cars-flow.e2e.test.ts b/tests/e2e/workflows/steps-07-09-cars-flow.e2e.test.ts index 6947c7593..6f65e3297 100644 --- a/tests/e2e/workflows/steps-07-09-cars-flow.e2e.test.ts +++ b/tests/e2e/workflows/steps-07-09-cars-flow.e2e.test.ts @@ -1,6 +1,10 @@ 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 { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors'; describe('Workflow – steps 7–9 cars flow (fixture-backed)', () => { let adapter: PlaywrightAutomationAdapter; @@ -18,7 +22,7 @@ describe('Workflow – steps 7–9 cars flow (fixture-backed)', () => { timeout: 5000, baseUrl, mode: 'mock', - } + }, ); await adapter.connect(); }); @@ -28,7 +32,7 @@ describe('Workflow – steps 7–9 cars flow (fixture-backed)', () => { 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)); const step7Result = await adapter.executeStep(StepId.create(7), { practice: 10, @@ -37,14 +41,43 @@ describe('Workflow – steps 7–9 cars flow (fixture-backed)', () => { }); 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)); const step8Result = await adapter.executeStep(StepId.create(8), {}); 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)); const step9Result = await adapter.executeStep(StepId.create(9), { - carSearch: 'Porsche 911 GT3 R', + carSearch: 'Acura ARX-06', }); 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); }); }); \ No newline at end of file diff --git a/tests/integration/infrastructure/BrowserModeIntegration.test.ts b/tests/integration/infrastructure/BrowserModeIntegration.test.ts index 8255cf07e..a5f553b86 100644 --- a/tests/integration/infrastructure/BrowserModeIntegration.test.ts +++ b/tests/integration/infrastructure/BrowserModeIntegration.test.ts @@ -9,18 +9,19 @@ import * as path from 'path'; * and runtime configuration via BrowserModeConfigLoader. */ -// Mock interfaces - will be replaced with actual imports in GREEN phase -interface PlaywrightAutomationAdapter { +type BrowserModeSource = 'env' | 'file' | 'default'; + +interface PlaywrightAutomationAdapterLike { connect(): Promise<{ success: boolean; error?: string }>; disconnect(): Promise; isConnected(): boolean; getBrowserMode(): 'headed' | 'headless'; - getBrowserModeSource(): 'GUI' | 'NODE_ENV'; + getBrowserModeSource(): BrowserModeSource; } describe('Browser Mode Integration - GREEN Phase', () => { const originalEnv = process.env; - let adapter: PlaywrightAutomationAdapter | null = null; + let adapter: PlaywrightAutomationAdapterLike | null = null; beforeEach(() => { process.env = { ...originalEnv }; diff --git a/tests/integration/infrastructure/CheckoutPriceExtractor.test.ts b/tests/integration/infrastructure/CheckoutPriceExtractor.test.ts index 1b1401c7b..e3a4370c0 100644 --- a/tests/integration/infrastructure/CheckoutPriceExtractor.test.ts +++ b/tests/integration/infrastructure/CheckoutPriceExtractor.test.ts @@ -9,15 +9,8 @@ import { CheckoutStateEnum } from '../../../packages/domain/value-objects/Checko * Tests verify HTML parsing for checkout price extraction and state detection. */ -interface Page { - locator(selector: string): Locator; -} - -interface Locator { - getAttribute(name: string): Promise; - innerHTML(): Promise; - textContent(): Promise; -} +type Page = ConstructorParameters[0]; +type Locator = ReturnType; describe('CheckoutPriceExtractor Integration', () => { let mockPage: Page; diff --git a/tests/unit/application/use-cases/ConfirmCheckoutUseCase.enhanced.test.ts b/tests/unit/application/use-cases/ConfirmCheckoutUseCase.enhanced.test.ts index 1ea19de15..2ea3257ee 100644 --- a/tests/unit/application/use-cases/ConfirmCheckoutUseCase.enhanced.test.ts +++ b/tests/unit/application/use-cases/ConfirmCheckoutUseCase.enhanced.test.ts @@ -31,7 +31,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => { const state = CheckoutState.ready(); vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue( - Result.ok({ price, state }) + Result.ok({ price, state, buttonHtml: '' }) ); vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue( @@ -60,7 +60,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => { const state = CheckoutState.ready(); vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue( - Result.ok({ price, state }) + Result.ok({ price, state, buttonHtml: '' }) ); vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue( @@ -80,7 +80,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => { const state = CheckoutState.ready(); vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue( - Result.ok({ price, state }) + Result.ok({ price, state, buttonHtml: '' }) ); vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue( @@ -100,7 +100,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => { const state = CheckoutState.ready(); vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue( - Result.ok({ price, state }) + Result.ok({ price, state, buttonHtml: '' }) ); vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue( @@ -118,7 +118,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => { const state = CheckoutState.insufficientFunds(); vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue( - Result.ok({ price, state }) + Result.ok({ price, state, buttonHtml: '' }) ); const result = await useCase.execute(); @@ -139,7 +139,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => { }; vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue( - Result.ok({ price, state }) + Result.ok({ price, state, buttonHtml: '' }) ); vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue( diff --git a/tests/unit/application/use-cases/StartAutomationSession.test.ts b/tests/unit/application/use-cases/StartAutomationSession.test.ts index bed1f0087..60294c973 100644 --- a/tests/unit/application/use-cases/StartAutomationSession.test.ts +++ b/tests/unit/application/use-cases/StartAutomationSession.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; import { StartAutomationSessionUseCase } from '../../../../packages/application/use-cases/StartAutomationSessionUseCase'; 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 { AutomationSession } from '../../../../packages/domain/entities/AutomationSession'; @@ -48,7 +48,7 @@ describe('StartAutomationSessionUseCase', () => { useCase = new StartAutomationSessionUseCase( mockAutomationEngine as unknown as IAutomationEngine, - mockBrowserAutomation as unknown as IBrowserAutomation, + mockBrowserAutomation as unknown as IScreenAutomation, mockSessionRepository as unknown as ISessionRepository ); }); diff --git a/tests/unit/infrastructure/adapters/SessionCookieStore.test.ts b/tests/unit/infrastructure/adapters/SessionCookieStore.test.ts index d37718f6e..773c37fd2 100644 --- a/tests/unit/infrastructure/adapters/SessionCookieStore.test.ts +++ b/tests/unit/infrastructure/adapters/SessionCookieStore.test.ts @@ -60,7 +60,7 @@ describe('SessionCookieStore - Cookie Validation', () => { const result = cookieStore.validateCookieConfiguration(targetUrl); 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 () => { @@ -81,7 +81,7 @@ describe('SessionCookieStore - Cookie Validation', () => { const result = cookieStore.validateCookieConfiguration(targetUrl); 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 () => { @@ -102,7 +102,7 @@ describe('SessionCookieStore - Cookie Validation', () => { const result = cookieStore.validateCookieConfiguration(targetUrl); 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 () => { @@ -123,14 +123,14 @@ describe('SessionCookieStore - Cookie Validation', () => { const result = cookieStore.validateCookieConfiguration(targetUrl); 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', () => { const result = cookieStore.validateCookieConfiguration(targetUrl); 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 () => { diff --git a/tsconfig.json b/tsconfig.json index 3cb30d138..dc84241ec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2022", "module": "ESNext", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "moduleResolution": "node", "esModuleInterop": true, "strict": true, @@ -17,7 +17,8 @@ "packages/*": ["packages/*"], "apps/*": ["apps/*"] }, - "types": ["vitest/globals"] + "types": ["vitest/globals", "node"], + "jsx": "react-jsx" }, "include": [ "packages/**/*", @@ -27,6 +28,7 @@ "exclude": [ "node_modules", "dist", - "**/*.js" + "**/*.js", + "tests/e2e/step-definitions/automation.steps.ts" ] } \ No newline at end of file diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 68612b808..ce4465b20 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -1,16 +1,19 @@ import { defineConfig } from 'vitest/config'; - + /** * E2E Test Configuration * * IMPORTANT: E2E tests run against real OS automation. * This configuration includes strict timeouts to prevent hanging. */ +const RUN_REAL_AUTOMATION_SMOKE = process.env.RUN_REAL_AUTOMATION_SMOKE === '1'; + export default defineConfig({ test: { globals: true, environment: 'node', 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 // Individual tests: 30 seconds max testTimeout: 30000,