wip
This commit is contained in:
@@ -12,15 +12,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: [
|
external: [
|
||||||
'@nut-tree-fork/nut-js',
|
|
||||||
'@nut-tree-fork/libnut',
|
|
||||||
'@nut-tree-fork/libnut-darwin',
|
|
||||||
'@nut-tree-fork/libnut-linux',
|
|
||||||
'@nut-tree-fork/libnut-win32',
|
|
||||||
'@nut-tree-fork/node-mac-permissions',
|
|
||||||
'@nut-tree-fork/default-clipboard-provider',
|
|
||||||
'@nut-tree-fork/provider-interfaces',
|
|
||||||
'@nut-tree-fork/shared',
|
|
||||||
'bufferutil',
|
'bufferutil',
|
||||||
'utf-8-validate',
|
'utf-8-validate',
|
||||||
'playwright',
|
'playwright',
|
||||||
|
|||||||
@@ -175,10 +175,10 @@ export class DIContainer {
|
|||||||
private static instance: DIContainer;
|
private static instance: DIContainer;
|
||||||
|
|
||||||
private logger: ILogger;
|
private logger: ILogger;
|
||||||
private sessionRepository: ISessionRepository;
|
private sessionRepository!: ISessionRepository;
|
||||||
private browserAutomation: PlaywrightAutomationAdapter | MockBrowserAutomationAdapter;
|
private browserAutomation!: PlaywrightAutomationAdapter | MockBrowserAutomationAdapter;
|
||||||
private automationEngine: IAutomationEngine;
|
private automationEngine!: IAutomationEngine;
|
||||||
private startAutomationUseCase: StartAutomationSessionUseCase;
|
private startAutomationUseCase!: StartAutomationSessionUseCase;
|
||||||
private checkAuthenticationUseCase: CheckAuthenticationUseCase | null = null;
|
private checkAuthenticationUseCase: CheckAuthenticationUseCase | null = null;
|
||||||
private initiateLoginUseCase: InitiateLoginUseCase | null = null;
|
private initiateLoginUseCase: InitiateLoginUseCase | null = null;
|
||||||
private clearSessionUseCase: ClearSessionUseCase | null = null;
|
private clearSessionUseCase: ClearSessionUseCase | null = null;
|
||||||
|
|||||||
@@ -372,7 +372,8 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
|||||||
mainWindow.webContents.send('automation-event', ev);
|
mainWindow.webContents.send('automation-event', ev);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug?.('Failed to forward automation-event', e);
|
const error = e instanceof Error ? e : new Error(String(e));
|
||||||
|
logger.debug?.('Failed to forward automation-event', { error });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
lifecycleSubscribed = true;
|
lifecycleSubscribed = true;
|
||||||
@@ -380,7 +381,8 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug?.('Failed to subscribe to adapter lifecycle events', e);
|
const error = e instanceof Error ? e : new Error(String(e));
|
||||||
|
logger.debug?.('Failed to subscribe to adapter lifecycle events', { error });
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
157
docs/TESTS.md
157
docs/TESTS.md
@@ -700,15 +700,15 @@ test('should create league', async ({ page }) => {
|
|||||||
|
|
||||||
## Real E2E Testing Strategy (No Mocks)
|
## Real E2E Testing Strategy (No Mocks)
|
||||||
|
|
||||||
GridPilot requires two distinct E2E testing strategies due to the nature of its automation adapters:
|
GridPilot focuses its real E2E testing strategy on browser-driven automation:
|
||||||
|
|
||||||
1. **Strategy A (Docker)**: Test `BrowserDevToolsAdapter` with Puppeteer against a fixture server
|
1. **Strategy A (Docker)**: Test `BrowserDevToolsAdapter` with Playwright or similar browser tooling against a fixture server
|
||||||
2. **Strategy B (Native macOS)**: Test `NutJsAutomationAdapter` on real hardware with display access
|
2. **Strategy B (Native macOS, legacy)**: Historical native OS-level automation on real hardware (now removed)
|
||||||
|
|
||||||
### Constraint: iRacing Terms of Service
|
### Constraint: iRacing Terms of Service
|
||||||
|
|
||||||
- **Production**: nut.js OS-level automation only (no Puppeteer/CDP for actual iRacing automation)
|
- **Production**: Native OS-level automation only (no browser DevTools/CDP for actual iRacing automation)
|
||||||
- **Testing**: Puppeteer CAN be used to test `BrowserDevToolsAdapter` against static HTML fixtures
|
- **Testing**: Playwright-driven automation CAN be used against static HTML fixtures
|
||||||
|
|
||||||
### Test Architecture Overview
|
### Test Architecture Overview
|
||||||
|
|
||||||
@@ -720,11 +720,7 @@ graph TB
|
|||||||
HC --> BDA[BrowserDevToolsAdapter Tests]
|
HC --> BDA[BrowserDevToolsAdapter Tests]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Native E2E - macOS Runner
|
%% Legacy native OS-level automation tests have been removed.
|
||||||
SCR[Screen Capture] --> TM[Template Matching Tests]
|
|
||||||
WF[Window Focus Tests] --> NJA[NutJsAutomationAdapter Tests]
|
|
||||||
KB[Keyboard/Mouse Tests] --> NJA
|
|
||||||
end
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -920,7 +916,7 @@ describe('E2E: BrowserDevToolsAdapter - Docker Environment', () => {
|
|||||||
### Strategy B: Native macOS E2E Tests
|
### Strategy B: Native macOS E2E Tests
|
||||||
|
|
||||||
#### Purpose
|
#### Purpose
|
||||||
Test OS-level screen automation using nut.js on real hardware. These tests CANNOT run in Docker because nut.js requires actual display access.
|
Test OS-level screen automation on real hardware. These tests CANNOT run in Docker because native automation requires actual display access.
|
||||||
|
|
||||||
#### Requirements
|
#### Requirements
|
||||||
- macOS CI runner with display access
|
- macOS CI runner with display access
|
||||||
@@ -928,128 +924,17 @@ Test OS-level screen automation using nut.js on real hardware. These tests CANNO
|
|||||||
- Accessibility permissions enabled
|
- Accessibility permissions enabled
|
||||||
- Real Chrome/browser window visible
|
- Real Chrome/browser window visible
|
||||||
|
|
||||||
#### BDD Scenarios for Native E2E
|
#### BDD Scenarios for Native E2E (Legacy)
|
||||||
|
|
||||||
```gherkin
|
> Historical note: previous native OS-level automation scenarios have been retired.
|
||||||
Feature: NutJsAutomationAdapter OS-Level Automation
|
> Real-world coverage is now provided by Playwright-based workflows and fixture-backed
|
||||||
As the automation engine
|
> automation; native OS-level adapters are no longer part of the supported stack.
|
||||||
I want to perform OS-level screen automation
|
|
||||||
So that I can interact with iRacing without browser DevTools
|
|
||||||
|
|
||||||
Background:
|
#### Test Implementation Structure (Legacy)
|
||||||
Given I am running on macOS with display access
|
|
||||||
And accessibility permissions are granted
|
|
||||||
And screen recording permissions are granted
|
|
||||||
|
|
||||||
Scenario: Screen capture functionality
|
Previous native OS-level adapter tests have been removed. The current
|
||||||
When I capture the full screen
|
E2E coverage relies on Playwright-driven automation and fixture-backed
|
||||||
Then a valid image buffer should be returned
|
flows as described in the Docker-based strategy above.
|
||||||
And the image dimensions should match screen resolution
|
|
||||||
|
|
||||||
Scenario: Window focus management
|
|
||||||
Given a Chrome window titled "iRacing" is open
|
|
||||||
When I focus the browser window
|
|
||||||
Then the Chrome window should become the active window
|
|
||||||
|
|
||||||
Scenario: Template matching detection
|
|
||||||
Given I have a template image for the "Create Race" button
|
|
||||||
And the iRacing hosted racing page is visible
|
|
||||||
When I search for the template on screen
|
|
||||||
Then the template should be found
|
|
||||||
And the location should have confidence > 0.8
|
|
||||||
|
|
||||||
Scenario: Mouse click at detected location
|
|
||||||
Given I have detected a button at coordinates 500,300
|
|
||||||
When I click at that location
|
|
||||||
Then the mouse should move to 500,300
|
|
||||||
And a left click should be performed
|
|
||||||
|
|
||||||
Scenario: Keyboard input simulation
|
|
||||||
Given a text field is focused
|
|
||||||
When I type "Test Session Name"
|
|
||||||
Then the text should be entered character by character
|
|
||||||
With appropriate delays between keystrokes
|
|
||||||
|
|
||||||
Scenario: Login state detection
|
|
||||||
Given the iRacing login page is displayed
|
|
||||||
When I detect the login state
|
|
||||||
Then the result should indicate logged out
|
|
||||||
And the login form indicator should be detected
|
|
||||||
|
|
||||||
Scenario: Safe automation - no checkout
|
|
||||||
Given I am on the Track Conditions step
|
|
||||||
When I execute step 18
|
|
||||||
Then no click should be performed on the checkout button
|
|
||||||
And the automation should report safety stop
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Test Implementation Structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// tests/e2e/native/nutJsAdapter.e2e.test.ts
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import { NutJsAutomationAdapter } from '@infrastructure/adapters/automation/NutJsAutomationAdapter';
|
|
||||||
|
|
||||||
describe('E2E: NutJsAutomationAdapter - Native macOS', () => {
|
|
||||||
let adapter: NutJsAutomationAdapter;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
// Skip if not on macOS with display
|
|
||||||
if (process.platform !== 'darwin' || !process.env.DISPLAY_AVAILABLE) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
adapter = new NutJsAutomationAdapter({
|
|
||||||
mouseSpeed: 500,
|
|
||||||
keyboardDelay: 25,
|
|
||||||
defaultTimeout: 10000,
|
|
||||||
});
|
|
||||||
await adapter.connect();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (adapter?.isConnected()) {
|
|
||||||
await adapter.disconnect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Screen Capture', () => {
|
|
||||||
it('should capture full screen', async () => {
|
|
||||||
const result = await adapter.captureScreen();
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.imageData).toBeDefined();
|
|
||||||
expect(result.dimensions.width).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should capture specific region', async () => {
|
|
||||||
const region = { x: 100, y: 100, width: 200, height: 200 };
|
|
||||||
const result = await adapter.captureScreen(region);
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Window Focus', () => {
|
|
||||||
it('should focus Chrome window', async () => {
|
|
||||||
const result = await adapter.focusBrowserWindow('Chrome');
|
|
||||||
// May fail if Chrome not open, which is acceptable
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Template Matching', () => {
|
|
||||||
it('should find element by template', async () => {
|
|
||||||
const template = {
|
|
||||||
id: 'test-button',
|
|
||||||
imagePath: './resources/templates/test-button.png',
|
|
||||||
confidence: 0.8,
|
|
||||||
};
|
|
||||||
const location = await adapter.findElement(template);
|
|
||||||
// Template may not be on screen - test structure only
|
|
||||||
expect(location === null || location.confidence > 0).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1218,10 +1103,8 @@ CHROME_WS_ENDPOINT=ws://localhost:9222
|
|||||||
FIXTURE_BASE_URL=http://localhost:3456
|
FIXTURE_BASE_URL=http://localhost:3456
|
||||||
E2E_TIMEOUT=120000
|
E2E_TIMEOUT=120000
|
||||||
|
|
||||||
# Native E2E Configuration
|
# Native E2E Configuration (legacy)
|
||||||
DISPLAY_AVAILABLE=true
|
DISPLAY_AVAILABLE=true
|
||||||
NUT_JS_MOUSE_SPEED=500
|
|
||||||
NUT_JS_KEYBOARD_DELAY=25
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1265,9 +1148,9 @@ For the iRacing hosted-session automation, confidence is provided by these concr
|
|||||||
- Example: [`steps-07-09-cars-flow.e2e.test.ts`](tests/e2e/workflows/steps-07-09-cars-flow.e2e.test.ts:1) exercises cross-step cars flow, while [`full-hosted-session.workflow.e2e.test.ts`](tests/e2e/workflows/full-hosted-session.workflow.e2e.test.ts:1) runs a full 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.
|
- 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.
|
- Additional workflow scenarios cover mid-flow failure using [`MockBrowserAutomationAdapter`](packages/infrastructure/adapters/automation/engine/MockBrowserAutomationAdapter.ts:1), ensuring failure states and diagnostics are surfaced without emitting false confirmations.
|
||||||
|
|
||||||
- **Opt-in real-world automation smoke tests (true iRacing / NutJs)**
|
- **Opt-in real-world automation smoke tests (legacy)**
|
||||||
- The legacy real iRacing automation suite [`automation.e2e.test.ts`](tests/e2e/automation.e2e.test.ts:1) is treated as a smoke-only, opt-in layer.
|
- The legacy real iRacing automation suite [`automation.e2e.test.ts`](tests/e2e/automation.e2e.test.ts:1) is now a skipped, documentation-only layer.
|
||||||
- It is gated by `RUN_REAL_AUTOMATION_SMOKE=1` and should not run in normal CI; it exists to validate that NutJs / template-based automation still matches the real UI for a small number of manual smoke runs on a prepared macOS environment.
|
- It is gated by `RUN_REAL_AUTOMATION_SMOKE=1` but no longer performs native OS-level automation; real confidence comes from Playwright-based fixture and workflow suites.
|
||||||
|
|
||||||
#### Confidence expectations
|
#### Confidence expectations
|
||||||
|
|
||||||
@@ -1277,7 +1160,7 @@ For the iRacing hosted-session automation, confidence is provided by these concr
|
|||||||
- All **step E2E tests** under [`tests/e2e/steps`](tests/e2e/steps:1).
|
- All **step E2E tests** under [`tests/e2e/steps`](tests/e2e/steps:1).
|
||||||
- All **workflow E2E tests** under [`tests/e2e/workflows`](tests/e2e/workflows:1).
|
- All **workflow E2E tests** under [`tests/e2e/workflows`](tests/e2e/workflows:1).
|
||||||
|
|
||||||
- The **real-world smoke suite** in [`tests/e2e/automation.e2e.test.ts`](tests/e2e/automation.e2e.test.ts:1) is an additional, manual confidence layer and should be run only on configured machines when validating large changes to NutJs automation, template packs, or iRacing UI assumptions.
|
- The **real-world smoke suite** in [`tests/e2e/automation.e2e.test.ts`](tests/e2e/automation.e2e.test.ts:1) remains as historical documentation and should not be relied upon for validating changes; instead, update and extend the Playwright-based E2E and workflow suites.
|
||||||
|
|
||||||
- When adding new behavior:
|
- When adding new behavior:
|
||||||
- Prefer **unit tests** for domain/application changes.
|
- Prefer **unit tests** for domain/application changes.
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export class OverlaySyncService implements IOverlaySyncPort {
|
|||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
try {
|
try {
|
||||||
this.lifecycleEmitter.offLifecycle(cb)
|
this.lifecycleEmitter.offLifecycle(cb)
|
||||||
} catch (e) {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,36 +59,45 @@ export class OverlaySyncService implements IOverlaySyncPort {
|
|||||||
let resolveAck: (ack: ActionAck) => void = () => {}
|
let resolveAck: (ack: ActionAck) => void = () => {}
|
||||||
const promise = new Promise<ActionAck>((resolve) => {
|
const promise = new Promise<ActionAck>((resolve) => {
|
||||||
resolveAck = resolve
|
resolveAck = resolve
|
||||||
// subscribe
|
|
||||||
try {
|
try {
|
||||||
this.lifecycleEmitter.onLifecycle(cb)
|
this.lifecycleEmitter.onLifecycle(cb)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger?.error?.('OverlaySyncService: failed to subscribe to lifecycleEmitter', e)
|
const error = e instanceof Error ? e : new Error(String(e))
|
||||||
|
this.logger?.error?.('OverlaySyncService: failed to subscribe to lifecycleEmitter', error, {
|
||||||
|
actionId: action.id,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// publish overlay request (best-effort)
|
|
||||||
try {
|
try {
|
||||||
this.publisher.publish({
|
void this.publisher.publish({
|
||||||
type: 'modal-opened',
|
type: 'modal-opened',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
payload: { actionId: action.id, label: action.label },
|
payload: { actionId: action.id, label: action.label },
|
||||||
actionId: action.id,
|
actionId: action.id,
|
||||||
} as AutomationEvent)
|
} as AutomationEvent)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger?.warn?.('OverlaySyncService: publisher.publish failed', e)
|
const error = e instanceof Error ? e : new Error(String(e))
|
||||||
|
this.logger?.warn?.('OverlaySyncService: publisher.publish failed', {
|
||||||
|
actionId: action.id,
|
||||||
|
error,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// timeout handling
|
|
||||||
const timeoutPromise = new Promise<ActionAck>((res) => {
|
const timeoutPromise = new Promise<ActionAck>((res) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
settled = true
|
settled = true
|
||||||
cleanup()
|
cleanup()
|
||||||
this.logger?.info?.('OverlaySyncService: timeout waiting for confirmation', { actionId: action.id, timeoutMs })
|
this.logger?.info?.('OverlaySyncService: timeout waiting for confirmation', {
|
||||||
// log recent events truncated
|
actionId: action.id,
|
||||||
|
timeoutMs,
|
||||||
|
})
|
||||||
const lastEvents = seenEvents.slice(-10)
|
const lastEvents = seenEvents.slice(-10)
|
||||||
this.logger?.debug?.('OverlaySyncService: recent lifecycle events', lastEvents)
|
this.logger?.debug?.('OverlaySyncService: recent lifecycle events', {
|
||||||
|
actionId: action.id,
|
||||||
|
events: lastEvents,
|
||||||
|
})
|
||||||
res({ id: action.id, status: 'tentative', reason: 'timeout' })
|
res({ id: action.id, status: 'tentative', reason: 'timeout' })
|
||||||
}
|
}
|
||||||
}, timeoutMs)
|
}, timeoutMs)
|
||||||
@@ -98,7 +107,6 @@ export class OverlaySyncService implements IOverlaySyncPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async cancelAction(actionId: string): Promise<void> {
|
async cancelAction(actionId: string): Promise<void> {
|
||||||
// best-effort: publish cancellation
|
|
||||||
try {
|
try {
|
||||||
await this.publisher.publish({
|
await this.publisher.publish({
|
||||||
type: 'panel-missing',
|
type: 'panel-missing',
|
||||||
@@ -106,7 +114,11 @@ export class OverlaySyncService implements IOverlaySyncPort {
|
|||||||
actionId,
|
actionId,
|
||||||
} as AutomationEvent)
|
} as AutomationEvent)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger?.warn?.('OverlaySyncService: cancelAction publish failed', e)
|
const error = e instanceof Error ? e : new Error(String(e))
|
||||||
|
this.logger?.warn?.('OverlaySyncService: cancelAction publish failed', {
|
||||||
|
actionId,
|
||||||
|
error,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,22 +16,15 @@ export class VerifyAuthenticatedPageUseCase {
|
|||||||
const result = await this.authService.verifyPageAuthentication();
|
const result = await this.authService.verifyPageAuthentication();
|
||||||
|
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
return Result.err(result.error);
|
const error = result.error ?? new Error('Page verification failed');
|
||||||
|
return Result.err<BrowserAuthenticationState>(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const browserState = result.unwrap();
|
const browserState = result.unwrap();
|
||||||
|
return Result.ok<BrowserAuthenticationState>(browserState);
|
||||||
// Log verification result
|
|
||||||
if (browserState.isFullyAuthenticated()) {
|
|
||||||
// Success case - no logging needed in use case
|
|
||||||
} else if (browserState.requiresReauthentication()) {
|
|
||||||
// Requires re-auth - caller should handle
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok(browserState);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
return Result.err(new Error(`Page verification failed: ${message}`));
|
return Result.err<BrowserAuthenticationState>(new Error(`Page verification failed: ${message}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
export interface HostedSessionConfig {
|
export interface HostedSessionConfig {
|
||||||
sessionName: string;
|
sessionName: string;
|
||||||
serverName: string;
|
|
||||||
password: string;
|
|
||||||
adminPassword: string;
|
|
||||||
maxDrivers: number;
|
|
||||||
trackId: string;
|
trackId: string;
|
||||||
carIds: string[];
|
carIds: string[];
|
||||||
|
|
||||||
|
// Optional fields for extended configuration.
|
||||||
|
serverName?: string;
|
||||||
|
password?: string;
|
||||||
|
adminPassword?: string;
|
||||||
|
maxDrivers?: number;
|
||||||
|
|
||||||
/** Search term for car selection (alternative to carIds) */
|
/** Search term for car selection (alternative to carIds) */
|
||||||
carSearch?: string;
|
carSearch?: string;
|
||||||
/** Search term for track selection (alternative to trackId) */
|
/** Search term for track selection (alternative to trackId) */
|
||||||
trackSearch?: string;
|
trackSearch?: string;
|
||||||
weatherType: 'static' | 'dynamic';
|
|
||||||
timeOfDay: 'morning' | 'afternoon' | 'evening' | 'night';
|
weatherType?: 'static' | 'dynamic';
|
||||||
sessionDuration: number;
|
timeOfDay?: 'morning' | 'afternoon' | 'evening' | 'night';
|
||||||
practiceLength: number;
|
sessionDuration?: number;
|
||||||
qualifyingLength: number;
|
practiceLength?: number;
|
||||||
warmupLength: number;
|
qualifyingLength?: number;
|
||||||
raceLength: number;
|
warmupLength?: number;
|
||||||
startType: 'standing' | 'rolling';
|
raceLength?: number;
|
||||||
restarts: 'single-file' | 'double-file';
|
startType?: 'standing' | 'rolling';
|
||||||
damageModel: 'off' | 'limited' | 'realistic';
|
restarts?: 'single-file' | 'double-file';
|
||||||
trackState: 'auto' | 'clean' | 'moderately-low' | 'moderately-high' | 'optimum';
|
damageModel?: 'off' | 'limited' | 'realistic';
|
||||||
|
trackState?: 'auto' | 'clean' | 'moderately-low' | 'moderately-high' | 'optimum';
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,8 @@ interface Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Locator {
|
interface Locator {
|
||||||
|
first(): Locator;
|
||||||
|
locator(selector: string): Locator;
|
||||||
getAttribute(name: string): Promise<string | null>;
|
getAttribute(name: string): Promise<string | null>;
|
||||||
innerHTML(): Promise<string>;
|
innerHTML(): Promise<string>;
|
||||||
textContent(): Promise<string | null>;
|
textContent(): Promise<string | null>;
|
||||||
|
|||||||
@@ -277,14 +277,13 @@ export class SessionCookieStore {
|
|||||||
validateCookieConfiguration(targetUrl: string): Result<Cookie[]> {
|
validateCookieConfiguration(targetUrl: string): Result<Cookie[]> {
|
||||||
try {
|
try {
|
||||||
if (!this.cachedState || this.cachedState.cookies.length === 0) {
|
if (!this.cachedState || this.cachedState.cookies.length === 0) {
|
||||||
return Result.err('No cookies found in session store');
|
return Result.err<Cookie[]>(new Error('No cookies found in session store'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = this.validateCookiesForUrl(this.cachedState.cookies, targetUrl, true);
|
return this.validateCookiesForUrl(this.cachedState.cookies, targetUrl, true);
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
return Result.err(`Cookie validation failed: ${message}`);
|
return Result.err<Cookie[]>(new Error(`Cookie validation failed: ${message}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,62 +298,57 @@ export class SessionCookieStore {
|
|||||||
requireAuthCookies = false
|
requireAuthCookies = false
|
||||||
): Result<Cookie[]> {
|
): Result<Cookie[]> {
|
||||||
try {
|
try {
|
||||||
// Validate each cookie's domain/path
|
|
||||||
const validatedCookies: Cookie[] = [];
|
const validatedCookies: Cookie[] = [];
|
||||||
let firstValidationError: string | null = null;
|
let firstValidationError: Error | null = null;
|
||||||
|
|
||||||
for (const cookie of cookies) {
|
for (const cookie of cookies) {
|
||||||
try {
|
try {
|
||||||
new CookieConfiguration(cookie, targetUrl);
|
new CookieConfiguration(cookie, targetUrl);
|
||||||
validatedCookies.push(cookie);
|
validatedCookies.push(cookie);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
// Capture first validation error to return if all cookies fail
|
|
||||||
if (!firstValidationError) {
|
if (!firstValidationError) {
|
||||||
firstValidationError = message;
|
firstValidationError = err;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger?.warn('Cookie validation failed', {
|
this.logger?.warn('Cookie validation failed', {
|
||||||
name: cookie.name,
|
name: cookie.name,
|
||||||
error: message,
|
error: err.message,
|
||||||
});
|
});
|
||||||
// Skip invalid cookie, continue with others
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validatedCookies.length === 0) {
|
if (validatedCookies.length === 0) {
|
||||||
// Return the specific validation error from the first failed cookie
|
return Result.err<Cookie[]>(
|
||||||
return Result.err(firstValidationError || 'No valid cookies found for target URL');
|
firstValidationError ?? new Error('No valid cookies found for target URL')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check required cookies only if requested (for authentication validation)
|
|
||||||
if (requireAuthCookies) {
|
if (requireAuthCookies) {
|
||||||
const cookieNames = validatedCookies.map((c) => c.name.toLowerCase());
|
const cookieNames = validatedCookies.map((c) => c.name.toLowerCase());
|
||||||
|
|
||||||
// Check for irsso_members
|
|
||||||
const hasIrssoMembers = cookieNames.some((name) =>
|
const hasIrssoMembers = cookieNames.some((name) =>
|
||||||
name.includes('irsso_members') || name.includes('irsso')
|
name.includes('irsso_members') || name.includes('irsso')
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check for authtoken_members
|
|
||||||
const hasAuthtokenMembers = cookieNames.some((name) =>
|
const hasAuthtokenMembers = cookieNames.some((name) =>
|
||||||
name.includes('authtoken_members') || name.includes('authtoken')
|
name.includes('authtoken_members') || name.includes('authtoken')
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasIrssoMembers) {
|
if (!hasIrssoMembers) {
|
||||||
return Result.err('Required cookie missing: irsso_members');
|
return Result.err<Cookie[]>(new Error('Required cookie missing: irsso_members'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasAuthtokenMembers) {
|
if (!hasAuthtokenMembers) {
|
||||||
return Result.err('Required cookie missing: authtoken_members');
|
return Result.err<Cookie[]>(new Error('Required cookie missing: authtoken_members'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.ok(validatedCookies);
|
return Result.ok<Cookie[]>(validatedCookies);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
return Result.err(`Cookie validation failed: ${message}`);
|
return Result.err<Cookie[]>(new Error(`Cookie validation failed: ${err.message}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Browser, Page, BrowserContext } from 'playwright';
|
import type { Browser, Page, BrowserContext } from 'playwright';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { extractDom, ExportedElement } from '../../../../scripts/dom-export/domExtractor';
|
|
||||||
import { StepId } from '../../../../domain/value-objects/StepId';
|
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||||
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
|
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
|
||||||
import { BrowserAuthenticationState } from '../../../../domain/value-objects/BrowserAuthenticationState';
|
import { BrowserAuthenticationState } from '../../../../domain/value-objects/BrowserAuthenticationState';
|
||||||
@@ -775,6 +774,30 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
|||||||
}
|
}
|
||||||
|
|
||||||
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
|
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
|
||||||
|
const stepNumber = stepId.value;
|
||||||
|
|
||||||
|
if (!this.isRealMode() && this.config.baseUrl) {
|
||||||
|
if (stepNumber >= 2 && stepNumber <= this.totalSteps) {
|
||||||
|
try {
|
||||||
|
const fixture = getFixtureForStep(stepNumber);
|
||||||
|
if (fixture) {
|
||||||
|
const base = this.config.baseUrl.replace(/\/$/, '');
|
||||||
|
const url = `${base}/${fixture}`;
|
||||||
|
this.log('debug', 'Mock mode: navigating to fixture for step', {
|
||||||
|
step: stepNumber,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
await this.navigator.navigateToPage(url);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.log('debug', 'Mock mode fixture navigation failed (non-fatal)', {
|
||||||
|
step: stepNumber,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return this.stepOrchestrator.executeStep(stepId, config);
|
return this.stepOrchestrator.executeStep(stepId, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,36 +911,34 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
|||||||
*
|
*
|
||||||
* Error dumps are always kept and not subject to cleanup.
|
* Error dumps are always kept and not subject to cleanup.
|
||||||
*/
|
*/
|
||||||
private async saveDebugInfo(stepName: string, error: Error): Promise<{ screenshotPath?: string; htmlPath?: string; domPath?: string }> {
|
private async saveDebugInfo(stepName: string, error: Error): Promise<{ screenshotPath?: string; htmlPath?: string }> {
|
||||||
if (!this.page) return {};
|
if (!this.page) return {};
|
||||||
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
const baseName = `debug-error-${stepName}-${timestamp}`;
|
const baseName = `debug-error-${stepName}-${timestamp}`;
|
||||||
const debugDir = path.join(process.cwd(), 'debug-screenshots');
|
const debugDir = path.join(process.cwd(), 'debug-screenshots');
|
||||||
const result: { screenshotPath?: string; htmlPath?: string; domPath?: string } = {};
|
const result: { screenshotPath?: string; htmlPath?: string } = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.promises.mkdir(debugDir, { recursive: true });
|
await fs.promises.mkdir(debugDir, { recursive: true });
|
||||||
|
|
||||||
// Save screenshot
|
|
||||||
const screenshotPath = path.join(debugDir, `${baseName}.png`);
|
const screenshotPath = path.join(debugDir, `${baseName}.png`);
|
||||||
await this.page.screenshot({ path: screenshotPath, fullPage: true });
|
await this.page.screenshot({ path: screenshotPath, fullPage: true });
|
||||||
result.screenshotPath = screenshotPath;
|
result.screenshotPath = screenshotPath;
|
||||||
this.log('error', `Error debug screenshot saved: ${screenshotPath}`, { path: screenshotPath, error: error.message });
|
this.log('error', `Error debug screenshot saved: ${screenshotPath}`, {
|
||||||
|
path: screenshotPath,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
|
||||||
// Save HTML (cleaned to remove noise)
|
|
||||||
const htmlPath = path.join(debugDir, `${baseName}.html`);
|
const htmlPath = path.join(debugDir, `${baseName}.html`);
|
||||||
const html = await this.page.evaluate(() => {
|
const html = await this.page.evaluate(() => {
|
||||||
// Clone the document
|
|
||||||
const root = document.documentElement.cloneNode(true) as HTMLElement;
|
const root = document.documentElement.cloneNode(true) as HTMLElement;
|
||||||
|
|
||||||
// Remove noise elements
|
|
||||||
['script', 'noscript', 'meta', 'base', 'style', 'link', 'iframe',
|
['script', 'noscript', 'meta', 'base', 'style', 'link', 'iframe',
|
||||||
'picture', 'source', 'svg', 'path', 'img', 'canvas', 'video', 'audio']
|
'picture', 'source', 'svg', 'path', 'img', 'canvas', 'video', 'audio']
|
||||||
.forEach(sel => root.querySelectorAll(sel).forEach(n => n.remove()));
|
.forEach((sel) => root.querySelectorAll(sel).forEach((n) => n.remove()));
|
||||||
|
|
||||||
// Remove empty non-interactive elements
|
root.querySelectorAll('*').forEach((n) => {
|
||||||
root.querySelectorAll('*').forEach(n => {
|
|
||||||
const text = (n.textContent || '').trim();
|
const text = (n.textContent || '').trim();
|
||||||
const interactive = n.matches('a,button,input,select,textarea,option,label');
|
const interactive = n.matches('a,button,input,select,textarea,option,label');
|
||||||
if (!interactive && text === '' && n.children.length === 0) {
|
if (!interactive && text === '' && n.children.length === 0) {
|
||||||
@@ -930,22 +951,6 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
|||||||
await fs.promises.writeFile(htmlPath, html);
|
await fs.promises.writeFile(htmlPath, html);
|
||||||
result.htmlPath = htmlPath;
|
result.htmlPath = htmlPath;
|
||||||
this.log('error', `Error debug HTML saved: ${htmlPath}`, { path: htmlPath });
|
this.log('error', `Error debug HTML saved: ${htmlPath}`, { path: htmlPath });
|
||||||
|
|
||||||
// Save structural DOM export alongside HTML
|
|
||||||
try {
|
|
||||||
await this.page.evaluate(() => {
|
|
||||||
(window as any).__name = (window as any).__name || ((fn: any) => fn);
|
|
||||||
});
|
|
||||||
const items = (await this.page.evaluate(
|
|
||||||
extractDom as () => ExportedElement[]
|
|
||||||
)) as unknown as ExportedElement[];
|
|
||||||
const domPath = path.join(debugDir, `${baseName}.dom.json`);
|
|
||||||
await fs.promises.writeFile(domPath, JSON.stringify(items, null, 2), 'utf8');
|
|
||||||
result.domPath = domPath;
|
|
||||||
this.log('error', `Error debug DOM saved: ${domPath}`, { path: domPath });
|
|
||||||
} catch (domErr) {
|
|
||||||
this.log('warn', 'Failed to save error debug DOM', { error: String(domErr) });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.log('warn', 'Failed to save error debug info', { error: String(e) });
|
this.log('warn', 'Failed to save error debug info', { error: String(e) });
|
||||||
}
|
}
|
||||||
@@ -960,39 +965,36 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
|||||||
* Files are named with "before-step-N" prefix and old snapshots are cleaned up
|
* Files are named with "before-step-N" prefix and old snapshots are cleaned up
|
||||||
* to avoid disk bloat (keeps only last MAX_BEFORE_SNAPSHOTS).
|
* to avoid disk bloat (keeps only last MAX_BEFORE_SNAPSHOTS).
|
||||||
*/
|
*/
|
||||||
private async saveProactiveDebugInfo(step: number): Promise<{ screenshotPath?: string; htmlPath?: string; domPath?: string }> {
|
private async saveProactiveDebugInfo(step: number): Promise<{ screenshotPath?: string; htmlPath?: string }> {
|
||||||
if (!this.page) return {};
|
if (!this.page) return {};
|
||||||
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
const baseName = `debug-before-step-${step}-${timestamp}`;
|
const baseName = `debug-before-step-${step}-${timestamp}`;
|
||||||
const debugDir = path.join(process.cwd(), 'debug-screenshots');
|
const debugDir = path.join(process.cwd(), 'debug-screenshots');
|
||||||
const result: { screenshotPath?: string; htmlPath?: string; domPath?: string } = {};
|
const result: { screenshotPath?: string; htmlPath?: string } = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.promises.mkdir(debugDir, { recursive: true });
|
await fs.promises.mkdir(debugDir, { recursive: true });
|
||||||
|
|
||||||
// Clean up old "before" snapshots first
|
|
||||||
await this.cleanupOldBeforeSnapshots(debugDir);
|
await this.cleanupOldBeforeSnapshots(debugDir);
|
||||||
|
|
||||||
// Save screenshot
|
|
||||||
const screenshotPath = path.join(debugDir, `${baseName}.png`);
|
const screenshotPath = path.join(debugDir, `${baseName}.png`);
|
||||||
await this.page.screenshot({ path: screenshotPath, fullPage: true });
|
await this.page.screenshot({ path: screenshotPath, fullPage: true });
|
||||||
result.screenshotPath = screenshotPath;
|
result.screenshotPath = screenshotPath;
|
||||||
this.log('info', `Pre-step screenshot saved: ${screenshotPath}`, { path: screenshotPath, step });
|
this.log('info', `Pre-step screenshot saved: ${screenshotPath}`, {
|
||||||
|
path: screenshotPath,
|
||||||
|
step,
|
||||||
|
});
|
||||||
|
|
||||||
// Save HTML (cleaned to remove noise)
|
|
||||||
const htmlPath = path.join(debugDir, `${baseName}.html`);
|
const htmlPath = path.join(debugDir, `${baseName}.html`);
|
||||||
const html = await this.page.evaluate(() => {
|
const html = await this.page.evaluate(() => {
|
||||||
// Clone the document
|
|
||||||
const root = document.documentElement.cloneNode(true) as HTMLElement;
|
const root = document.documentElement.cloneNode(true) as HTMLElement;
|
||||||
|
|
||||||
// Remove noise elements
|
|
||||||
['script', 'noscript', 'meta', 'base', 'style', 'link', 'iframe',
|
['script', 'noscript', 'meta', 'base', 'style', 'link', 'iframe',
|
||||||
'picture', 'source', 'svg', 'path', 'img', 'canvas', 'video', 'audio']
|
'picture', 'source', 'svg', 'path', 'img', 'canvas', 'video', 'audio']
|
||||||
.forEach(sel => root.querySelectorAll(sel).forEach(n => n.remove()));
|
.forEach((sel) => root.querySelectorAll(sel).forEach((n) => n.remove()));
|
||||||
|
|
||||||
// Remove empty non-interactive elements
|
root.querySelectorAll('*').forEach((n) => {
|
||||||
root.querySelectorAll('*').forEach(n => {
|
|
||||||
const text = (n.textContent || '').trim();
|
const text = (n.textContent || '').trim();
|
||||||
const interactive = n.matches('a,button,input,select,textarea,option,label');
|
const interactive = n.matches('a,button,input,select,textarea,option,label');
|
||||||
if (!interactive && text === '' && n.children.length === 0) {
|
if (!interactive && text === '' && n.children.length === 0) {
|
||||||
@@ -1005,24 +1007,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
|||||||
await fs.promises.writeFile(htmlPath, html);
|
await fs.promises.writeFile(htmlPath, html);
|
||||||
result.htmlPath = htmlPath;
|
result.htmlPath = htmlPath;
|
||||||
this.log('info', `Pre-step HTML saved: ${htmlPath}`, { path: htmlPath, step });
|
this.log('info', `Pre-step HTML saved: ${htmlPath}`, { path: htmlPath, step });
|
||||||
|
|
||||||
// Save structural DOM export alongside HTML
|
|
||||||
try {
|
|
||||||
await this.page.evaluate(() => {
|
|
||||||
(window as any).__name = (window as any).__name || ((fn: any) => fn);
|
|
||||||
});
|
|
||||||
const items = (await this.page.evaluate(
|
|
||||||
extractDom as () => ExportedElement[]
|
|
||||||
)) as unknown as ExportedElement[];
|
|
||||||
const domPath = path.join(debugDir, `${baseName}.dom.json`);
|
|
||||||
await fs.promises.writeFile(domPath, JSON.stringify(items, null, 2), 'utf8');
|
|
||||||
result.domPath = domPath;
|
|
||||||
this.log('info', `Pre-step DOM saved: ${domPath}`, { path: domPath, step });
|
|
||||||
} catch (domErr) {
|
|
||||||
this.log('warn', 'Failed to save proactive debug DOM', { error: String(domErr), step });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Don't fail step execution if debug save fails
|
|
||||||
this.log('warn', 'Failed to save proactive debug info', { error: String(e), step });
|
this.log('warn', 'Failed to save proactive debug info', { error: String(e), step });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -307,13 +307,8 @@ export class WizardStepOrchestrator {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 2:
|
case 2:
|
||||||
if (!this.isRealMode() && this.config.baseUrl) {
|
if (!this.isRealMode()) {
|
||||||
const fixture = getFixtureForStep(2);
|
break;
|
||||||
if (fixture) {
|
|
||||||
const base = this.config.baseUrl.replace(/\/$/, '');
|
|
||||||
await this.navigator.navigateToPage(`${base}/${fixture}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
await this.clickAction('create');
|
await this.clickAction('create');
|
||||||
break;
|
break;
|
||||||
@@ -351,11 +346,12 @@ export class WizardStepOrchestrator {
|
|||||||
'Race Information panel not found with fallback selector, dumping #create-race-wizard innerHTML',
|
'Race Information panel not found with fallback selector, dumping #create-race-wizard innerHTML',
|
||||||
{ selector: raceInfoFallback },
|
{ selector: raceInfoFallback },
|
||||||
);
|
);
|
||||||
const inner = await this.page!.evaluate(
|
const inner = await this.page!.evaluate(() => {
|
||||||
() =>
|
const doc = (globalThis as any).document as any;
|
||||||
document.querySelector('#create-race-wizard')?.innerHTML ||
|
return (
|
||||||
'',
|
doc?.querySelector('#create-race-wizard')?.innerHTML || ''
|
||||||
);
|
);
|
||||||
|
});
|
||||||
this.log(
|
this.log(
|
||||||
'debug',
|
'debug',
|
||||||
'create-race-wizard innerHTML (truncated)',
|
'create-race-wizard innerHTML (truncated)',
|
||||||
@@ -412,14 +408,57 @@ export class WizardStepOrchestrator {
|
|||||||
if (this.isRealMode()) {
|
if (this.isRealMode()) {
|
||||||
await this.waitForWizardStep('admins');
|
await this.waitForWizardStep('admins');
|
||||||
await this.checkWizardDismissed(step);
|
await this.checkWizardDismissed(step);
|
||||||
|
} else {
|
||||||
|
const adminSearch =
|
||||||
|
(config.adminSearch ?? config.admin) as string | undefined;
|
||||||
|
if (adminSearch) {
|
||||||
|
await this.fillField('adminSearch', String(adminSearch));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await this.clickNextButton('Time Limit');
|
await this.clickNextButton('Time Limit');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 6:
|
case 6:
|
||||||
if (this.isRealMode()) {
|
if (this.isRealMode()) {
|
||||||
await this.waitForWizardStep('admins');
|
await this.waitForWizardStep('admins');
|
||||||
await this.checkWizardDismissed(step);
|
await this.checkWizardDismissed(step);
|
||||||
|
} else {
|
||||||
|
const adminSearch =
|
||||||
|
(config.adminSearch ?? config.admin) as string | undefined;
|
||||||
|
if (adminSearch) {
|
||||||
|
await this.fillField('adminSearch', String(adminSearch));
|
||||||
|
const page = this.page;
|
||||||
|
if (page) {
|
||||||
|
await page.evaluate((term) => {
|
||||||
|
const doc = (globalThis as any).document as any;
|
||||||
|
if (!doc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root =
|
||||||
|
(doc.querySelector('#set-admins') as any) ?? doc.body;
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = Array.from(
|
||||||
|
(root as any).querySelectorAll(
|
||||||
|
'tbody[data-testid="admin-display-name-list"] tr',
|
||||||
|
),
|
||||||
|
) as any[];
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const needle = String(term).toLowerCase();
|
||||||
|
for (const r of rows) {
|
||||||
|
const text = String((r as any).textContent || '').toLowerCase();
|
||||||
|
if (text.includes(needle)) {
|
||||||
|
(r as any).setAttribute('data-selected-admin', 'true');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(rows[0] as any).setAttribute('data-selected-admin', 'true');
|
||||||
|
}, String(adminSearch));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await this.clickNextButton('Time Limit');
|
await this.clickNextButton('Time Limit');
|
||||||
break;
|
break;
|
||||||
@@ -553,11 +592,11 @@ export class WizardStepOrchestrator {
|
|||||||
|
|
||||||
case 9:
|
case 9:
|
||||||
this.log('info', 'Step 9: Validating we are still on Cars page');
|
this.log('info', 'Step 9: Validating we are still on Cars page');
|
||||||
|
|
||||||
if (this.isRealMode()) {
|
if (this.isRealMode()) {
|
||||||
const actualPage = await this.detectCurrentWizardPage();
|
const actualPage = await this.detectCurrentWizardPage();
|
||||||
const skipOffset = this.synchronizeStepCounter(step, actualPage);
|
const skipOffset = this.synchronizeStepCounter(step, actualPage);
|
||||||
|
|
||||||
if (skipOffset > 0) {
|
if (skipOffset > 0) {
|
||||||
this.log('info', `Step ${step} was auto-skipped by wizard`, {
|
this.log('info', `Step ${step} was auto-skipped by wizard`, {
|
||||||
actualPage,
|
actualPage,
|
||||||
@@ -565,7 +604,7 @@ export class WizardStepOrchestrator {
|
|||||||
});
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const wizardFooter = await this.page!
|
const wizardFooter = await this.page!
|
||||||
.locator('.wizard-footer')
|
.locator('.wizard-footer')
|
||||||
.innerText()
|
.innerText()
|
||||||
@@ -573,21 +612,21 @@ export class WizardStepOrchestrator {
|
|||||||
this.log('info', 'Step 9: Current wizard footer', {
|
this.log('info', 'Step 9: Current wizard footer', {
|
||||||
footer: wizardFooter,
|
footer: wizardFooter,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onTrackPage =
|
const onTrackPage =
|
||||||
wizardFooter.includes('Track Options') ||
|
wizardFooter.includes('Track Options') ||
|
||||||
(await this.page!
|
(await this.page!
|
||||||
.locator(IRACING_SELECTORS.wizard.stepContainers.track)
|
.locator(IRACING_SELECTORS.wizard.stepContainers.track)
|
||||||
.isVisible()
|
.isVisible()
|
||||||
.catch(() => false));
|
.catch(() => false));
|
||||||
|
|
||||||
if (onTrackPage) {
|
if (onTrackPage) {
|
||||||
const errorMsg = `FATAL: Step 9 attempted on Track page (Step 11) - navigation bug detected. Wizard footer: "${wizardFooter}"`;
|
const errorMsg = `FATAL: Step 9 attempted on Track page (Step 11) - navigation bug detected. Wizard footer: "${wizardFooter}"`;
|
||||||
this.log('error', errorMsg);
|
this.log('error', errorMsg);
|
||||||
throw new Error(errorMsg);
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = await this.validatePageState({
|
const validation = await this.validatePageState({
|
||||||
expectedStep: 'cars',
|
expectedStep: 'cars',
|
||||||
requiredSelectors: this.isRealMode()
|
requiredSelectors: this.isRealMode()
|
||||||
@@ -595,7 +634,7 @@ export class WizardStepOrchestrator {
|
|||||||
: ['#set-cars, .wizard-step[id*="cars"], .cars-panel'],
|
: ['#set-cars, .wizard-step[id*="cars"], .cars-panel'],
|
||||||
forbiddenSelectors: ['#set-track'],
|
forbiddenSelectors: ['#set-track'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (validation.isErr()) {
|
if (validation.isErr()) {
|
||||||
const errorMsg = `Step 9 validation error: ${
|
const errorMsg = `Step 9 validation error: ${
|
||||||
validation.error?.message ?? 'unknown error'
|
validation.error?.message ?? 'unknown error'
|
||||||
@@ -603,7 +642,7 @@ export class WizardStepOrchestrator {
|
|||||||
this.log('error', errorMsg);
|
this.log('error', errorMsg);
|
||||||
throw new Error(errorMsg);
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validationResult = validation.unwrap();
|
const validationResult = validation.unwrap();
|
||||||
this.log('info', 'Step 9 validation result', {
|
this.log('info', 'Step 9 validation result', {
|
||||||
isValid: validationResult.isValid,
|
isValid: validationResult.isValid,
|
||||||
@@ -611,7 +650,7 @@ export class WizardStepOrchestrator {
|
|||||||
missingSelectors: validationResult.missingSelectors,
|
missingSelectors: validationResult.missingSelectors,
|
||||||
unexpectedSelectors: validationResult.unexpectedSelectors,
|
unexpectedSelectors: validationResult.unexpectedSelectors,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validationResult.isValid) {
|
if (!validationResult.isValid) {
|
||||||
const errorMsg = `Step 9 FAILED validation: ${
|
const errorMsg = `Step 9 FAILED validation: ${
|
||||||
validationResult.message
|
validationResult.message
|
||||||
@@ -626,14 +665,16 @@ export class WizardStepOrchestrator {
|
|||||||
});
|
});
|
||||||
throw new Error(errorMsg);
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.log('info', 'Step 9 validation passed - confirmed on Cars page');
|
this.log('info', 'Step 9 validation passed - confirmed on Cars page');
|
||||||
|
|
||||||
|
const carIds = config.carIds as string[] | undefined;
|
||||||
|
const carSearchTerm =
|
||||||
|
(config.carSearch as string | undefined) ||
|
||||||
|
(config.car as string | undefined) ||
|
||||||
|
carIds?.[0];
|
||||||
|
|
||||||
if (this.isRealMode()) {
|
if (this.isRealMode()) {
|
||||||
const carIds = config.carIds as string[] | undefined;
|
|
||||||
const carSearchTerm =
|
|
||||||
config.carSearch || config.car || carIds?.[0];
|
|
||||||
|
|
||||||
if (carSearchTerm) {
|
if (carSearchTerm) {
|
||||||
await this.clickAddCarButton();
|
await this.clickAddCarButton();
|
||||||
await this.waitForAddCarModal();
|
await this.waitForAddCarModal();
|
||||||
@@ -647,11 +688,31 @@ export class WizardStepOrchestrator {
|
|||||||
|
|
||||||
await this.clickNextButton('Car Classes');
|
await this.clickNextButton('Car Classes');
|
||||||
} else {
|
} else {
|
||||||
if (config.carSearch) {
|
if (carSearchTerm) {
|
||||||
await this.fillField('carSearch', String(config.carSearch));
|
const page = this.page;
|
||||||
await this.clickAction('confirm');
|
if (page) {
|
||||||
|
await this.clickAddCarButton();
|
||||||
|
await this.waitForAddCarModal();
|
||||||
|
await this.fillField('carSearch', String(carSearchTerm));
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
try {
|
||||||
|
await this.selectFirstSearchResult();
|
||||||
|
} catch (e) {
|
||||||
|
this.log('debug', 'Step 9 mock mode: selectFirstSearchResult failed (non-fatal)', {
|
||||||
|
error: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log('info', 'Step 9 mock mode: selected car from JSON-backed list', {
|
||||||
|
carSearch: String(carSearchTerm),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.log(
|
||||||
|
'debug',
|
||||||
|
'Step 9 mock mode: no carSearch provided, skipping car addition',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await this.clickNextButton('Car Classes');
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -805,13 +866,23 @@ export class WizardStepOrchestrator {
|
|||||||
|
|
||||||
await this.waitForWizardStep('timeOfDay');
|
await this.waitForWizardStep('timeOfDay');
|
||||||
await this.checkWizardDismissed(step);
|
await this.checkWizardDismissed(step);
|
||||||
}
|
|
||||||
if (config.trackConfig) {
|
if (config.timeOfDay !== undefined) {
|
||||||
await this.selectDropdown('trackConfig', String(config.trackConfig));
|
await this.setSlider('timeOfDay', Number(config.timeOfDay));
|
||||||
}
|
}
|
||||||
await this.clickNextButton('Time of Day');
|
if (config.trackConfig) {
|
||||||
if (this.isRealMode()) {
|
await this.selectDropdown('trackConfig', String(config.trackConfig));
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.clickNextButton('Time of Day');
|
||||||
await this.waitForWizardStep('timeOfDay');
|
await this.waitForWizardStep('timeOfDay');
|
||||||
|
} else {
|
||||||
|
if (config.timeOfDay !== undefined) {
|
||||||
|
await this.setSlider('timeOfDay', Number(config.timeOfDay));
|
||||||
|
}
|
||||||
|
if (config.trackConfig) {
|
||||||
|
await this.selectDropdown('trackConfig', String(config.trackConfig));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -863,11 +934,12 @@ export class WizardStepOrchestrator {
|
|||||||
'Weather panel not found with fallback selector, dumping #create-race-wizard innerHTML',
|
'Weather panel not found with fallback selector, dumping #create-race-wizard innerHTML',
|
||||||
{ selector: weatherFallbackSelector },
|
{ selector: weatherFallbackSelector },
|
||||||
);
|
);
|
||||||
const inner = await this.page!.evaluate(
|
const inner = await this.page!.evaluate(() => {
|
||||||
() =>
|
const doc = (globalThis as any).document as any;
|
||||||
document.querySelector('#create-race-wizard')?.innerHTML ||
|
return (
|
||||||
'',
|
doc?.querySelector('#create-race-wizard')?.innerHTML || ''
|
||||||
);
|
);
|
||||||
|
});
|
||||||
this.log(
|
this.log(
|
||||||
'debug',
|
'debug',
|
||||||
'create-race-wizard innerHTML (truncated)',
|
'create-race-wizard innerHTML (truncated)',
|
||||||
@@ -889,14 +961,32 @@ export class WizardStepOrchestrator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
await this.checkWizardDismissed(step);
|
await this.checkWizardDismissed(step);
|
||||||
|
|
||||||
|
if (config.timeOfDay !== undefined) {
|
||||||
|
await this.setSlider('timeOfDay', Number(config.timeOfDay));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (config.weatherType) {
|
||||||
|
await this.selectDropdown('weatherType', String(config.weatherType));
|
||||||
|
}
|
||||||
|
if (config.temperature !== undefined && this.page) {
|
||||||
|
const tempSelector = IRACING_SELECTORS.steps.temperature;
|
||||||
|
const tempExists =
|
||||||
|
(await this.page
|
||||||
|
.locator(tempSelector)
|
||||||
|
.first()
|
||||||
|
.count()
|
||||||
|
.catch(() => 0)) > 0;
|
||||||
|
if (tempExists) {
|
||||||
|
await this.setSlider('temperature', Number(config.temperature));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (config.timeOfDay !== undefined) {
|
|
||||||
await this.setSlider('timeOfDay', Number(config.timeOfDay));
|
|
||||||
}
|
|
||||||
if (this.isRealMode()) {
|
if (this.isRealMode()) {
|
||||||
await this.dismissDatetimePickers();
|
await this.dismissDatetimePickers();
|
||||||
|
await this.clickNextButton('Weather');
|
||||||
}
|
}
|
||||||
await this.clickNextButton('Weather');
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 16:
|
case 16:
|
||||||
@@ -1000,6 +1090,10 @@ export class WizardStepOrchestrator {
|
|||||||
} else {
|
} else {
|
||||||
const valueStr = String(config.trackState);
|
const valueStr = String(config.trackState);
|
||||||
await this.page!.evaluate((trackStateValue) => {
|
await this.page!.evaluate((trackStateValue) => {
|
||||||
|
const doc = (globalThis as any).document as any;
|
||||||
|
if (!doc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const map: Record<string, number> = {
|
const map: Record<string, number> = {
|
||||||
'very-low': 10,
|
'very-low': 10,
|
||||||
low: 25,
|
low: 25,
|
||||||
@@ -1011,24 +1105,29 @@ export class WizardStepOrchestrator {
|
|||||||
};
|
};
|
||||||
const numeric = map[trackStateValue] ?? null;
|
const numeric = map[trackStateValue] ?? null;
|
||||||
const inputs = Array.from(
|
const inputs = Array.from(
|
||||||
document.querySelectorAll<HTMLInputElement>(
|
doc.querySelectorAll(
|
||||||
'input[id*="starting-track-state"], input[id*="track-state"], input[data-value]',
|
'input[id*="starting-track-state"], input[id*="track-state"], input[data-value]',
|
||||||
),
|
),
|
||||||
);
|
) as any[];
|
||||||
if (numeric !== null && inputs.length > 0) {
|
if (numeric !== null && inputs.length > 0) {
|
||||||
for (const inp of inputs) {
|
for (const inp of inputs) {
|
||||||
try {
|
try {
|
||||||
inp.value = String(numeric);
|
(inp as any).value = String(numeric);
|
||||||
(inp as any).dataset = (inp as any).dataset || {};
|
const ds =
|
||||||
(inp as any).dataset.value = String(numeric);
|
(inp as any).dataset || ((inp as any).dataset = {});
|
||||||
inp.setAttribute('data-value', String(numeric));
|
ds.value = String(numeric);
|
||||||
inp.dispatchEvent(
|
(inp as any).setAttribute?.(
|
||||||
new Event('input', { bubbles: true }),
|
'data-value',
|
||||||
|
String(numeric),
|
||||||
);
|
);
|
||||||
inp.dispatchEvent(
|
const Ev = (globalThis as any).Event;
|
||||||
new Event('change', { bubbles: true }),
|
(inp as any).dispatchEvent?.(
|
||||||
|
new Ev('input', { bubbles: true }),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
(inp as any).dispatchEvent?.(
|
||||||
|
new Ev('change', { bubbles: true }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,6 +280,30 @@ export class IRacingDomInteractor {
|
|||||||
const page = this.getPage();
|
const page = this.getPage();
|
||||||
|
|
||||||
if (!this.isRealMode()) {
|
if (!this.isRealMode()) {
|
||||||
|
const timeout = this.config.timeout;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const footerButtons = page.locator('.wizard-footer a.btn, .wizard-footer button');
|
||||||
|
const count = await footerButtons.count().catch(() => 0);
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
const targetText = nextStepName.toLowerCase();
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const button = footerButtons.nth(i);
|
||||||
|
const text = (await button.innerText().catch(() => '')).trim().toLowerCase();
|
||||||
|
if (text && text.includes(targetText)) {
|
||||||
|
await button.click({ timeout, force: true });
|
||||||
|
this.log('info', 'Clicked mock next button via footer text match', {
|
||||||
|
nextStepName,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
await this.clickAction('next');
|
await this.clickAction('next');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -346,7 +370,13 @@ export class IRacingDomInteractor {
|
|||||||
try {
|
try {
|
||||||
const count = await page.locator(h).first().count().catch(() => 0);
|
const count = await page.locator(h).first().count().catch(() => 0);
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
const tag = await page.locator(h).first().evaluate((el) => el.tagName.toLowerCase()).catch(() => '');
|
const tag = await page
|
||||||
|
.locator(h)
|
||||||
|
.first()
|
||||||
|
.evaluate((el: any) =>
|
||||||
|
String((el as any).tagName || '').toLowerCase(),
|
||||||
|
)
|
||||||
|
.catch(() => '');
|
||||||
if (tag === 'select') {
|
if (tag === 'select') {
|
||||||
try {
|
try {
|
||||||
await page.selectOption(h, value);
|
await page.selectOption(h, value);
|
||||||
@@ -514,7 +544,11 @@ export class IRacingDomInteractor {
|
|||||||
const count = await locator.count().catch(() => 0);
|
const count = await locator.count().catch(() => 0);
|
||||||
if (count === 0) continue;
|
if (count === 0) continue;
|
||||||
|
|
||||||
const tagName = await locator.evaluate((el) => el.tagName.toLowerCase()).catch(() => '');
|
const tagName = await locator
|
||||||
|
.evaluate((el: any) =>
|
||||||
|
String((el as any).tagName || '').toLowerCase(),
|
||||||
|
)
|
||||||
|
.catch(() => '');
|
||||||
const type = await locator.getAttribute('type').catch(() => '');
|
const type = await locator.getAttribute('type').catch(() => '');
|
||||||
|
|
||||||
if (tagName === 'input' && (type === 'checkbox' || type === 'radio')) {
|
if (tagName === 'input' && (type === 'checkbox' || type === 'radio')) {
|
||||||
@@ -648,7 +682,11 @@ export class IRacingDomInteractor {
|
|||||||
const count = await locator.count().catch(() => 0);
|
const count = await locator.count().catch(() => 0);
|
||||||
if (count === 0) continue;
|
if (count === 0) continue;
|
||||||
|
|
||||||
const tagName = await locator.evaluate((el) => el.tagName.toLowerCase()).catch(() => '');
|
const tagName = await locator
|
||||||
|
.evaluate((el: any) =>
|
||||||
|
String((el as any).tagName || '').toLowerCase(),
|
||||||
|
)
|
||||||
|
.catch(() => '');
|
||||||
if (tagName === 'input') {
|
if (tagName === 'input') {
|
||||||
const type = await locator.getAttribute('type').catch(() => '');
|
const type = await locator.getAttribute('type').catch(() => '');
|
||||||
if (type === 'range' || type === 'text' || type === 'number') {
|
if (type === 'range' || type === 'text' || type === 'number') {
|
||||||
@@ -746,7 +784,7 @@ export class IRacingDomInteractor {
|
|||||||
|
|
||||||
const addCarButtonSelector = this.isRealMode()
|
const addCarButtonSelector = this.isRealMode()
|
||||||
? IRACING_SELECTORS.steps.addCarButton
|
? IRACING_SELECTORS.steps.addCarButton
|
||||||
: '[data-action="add-car"]';
|
: `${IRACING_SELECTORS.steps.addCarButton}, [data-action="add-car"]`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.log('info', 'Clicking Add Car button to open modal');
|
this.log('info', 'Clicking Add Car button to open modal');
|
||||||
|
|||||||
@@ -97,20 +97,28 @@ export const IRACING_SELECTORS = {
|
|||||||
leagueRacingToggle: '#set-session-information .switch-checkbox, [data-toggle="leagueRacing"]',
|
leagueRacingToggle: '#set-session-information .switch-checkbox, [data-toggle="leagueRacing"]',
|
||||||
|
|
||||||
// Step 4: Server Details
|
// Step 4: Server Details
|
||||||
region: '#set-server-details select.form-control, #set-server-details [data-dropdown="region"], #set-server-details [data-dropdown], [data-dropdown="region"]',
|
region:
|
||||||
startNow: '#set-server-details .switch-checkbox, #set-server-details input[type="checkbox"], [data-toggle="startNow"], input[data-toggle="startNow"]',
|
'#set-server-details select.form-control, ' +
|
||||||
|
'#set-server-details [data-dropdown="region"], ' +
|
||||||
|
'#set-server-details [data-dropdown], ' +
|
||||||
|
'[data-dropdown="region"], ' +
|
||||||
|
'#set-server-details [role="radiogroup"] input[type="radio"]',
|
||||||
|
startNow:
|
||||||
|
'#set-server-details .switch-checkbox, ' +
|
||||||
|
'#set-server-details input[type="checkbox"], ' +
|
||||||
|
'[data-toggle="startNow"], ' +
|
||||||
|
'input[data-toggle="startNow"]',
|
||||||
|
|
||||||
// Step 5/6: Admins
|
// Step 5/6: Admins
|
||||||
adminSearch: 'input[placeholder*="Search"]',
|
adminSearch: 'input[placeholder*="Search"]',
|
||||||
adminList: '#set-admins table.table.table-striped, #set-admins .card-block table',
|
adminList: '#set-admins table.table.table-striped, #set-admins .card-block table',
|
||||||
addAdminButton: 'a.btn:has-text("Add an Admin")',
|
addAdminButton: 'a.btn:has-text("Add an Admin")',
|
||||||
|
|
||||||
// Step 7: Time Limits - Bootstrap-slider uses hidden input[type="text"] with id containing slider name
|
// Step 7: Time Limits - Bootstrap-slider uses hidden input[type="text"] with dynamic id
|
||||||
// Also targets the visible slider handle for interaction
|
// Fixtures show ids like time-limit-slider1764248520320
|
||||||
// Dumps show dynamic IDs like time-limit-slider1763726367635
|
practice: '#set-time-limit input[id*="time-limit-slider"]',
|
||||||
practice: 'label:has-text("Practice") ~ div input[id*="time-limit-slider"]',
|
qualify: '#set-time-limit input[id*="time-limit-slider"]',
|
||||||
qualify: 'label:has-text("Qualify") ~ div input[id*="time-limit-slider"]',
|
race: '#set-time-limit input[id*="time-limit-slider"]',
|
||||||
race: 'label:has-text("Race") ~ div input[id*="time-limit-slider"]',
|
|
||||||
|
|
||||||
// Step 8/9: Cars
|
// Step 8/9: Cars
|
||||||
carSearch: 'input[placeholder*="Search"]',
|
carSearch: 'input[placeholder*="Search"]',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { IAutomationEngine, ValidationResult } from '../../../application/ports/IAutomationEngine';
|
import { IAutomationEngine, ValidationResult } from '../../../../application/ports/IAutomationEngine';
|
||||||
import { HostedSessionConfig } from '../../../domain/entities/HostedSessionConfig';
|
import { HostedSessionConfig } from '../../../../domain/entities/HostedSessionConfig';
|
||||||
import { StepId } from '../../../domain/value-objects/StepId';
|
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||||
import type { IBrowserAutomation } from '../../../application/ports/IScreenAutomation';
|
import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation';
|
||||||
import { ISessionRepository } from '../../../application/ports/ISessionRepository';
|
import { ISessionRepository } from '../../../../application/ports/ISessionRepository';
|
||||||
import { getStepName } from './templates/IRacingTemplateMap';
|
import { getStepName } from './templates/IRacingTemplateMap';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,12 +14,13 @@ import { getStepName } from './templates/IRacingTemplateMap';
|
|||||||
* 3. Managing session state transitions
|
* 3. Managing session state transitions
|
||||||
*
|
*
|
||||||
* This is a REAL implementation that uses actual automation,
|
* This is a REAL implementation that uses actual automation,
|
||||||
* not a mock. Currently delegates to deprecated nut.js adapters for
|
* not a mock. Historically delegated to legacy native screen
|
||||||
* screen automation operations.
|
* automation adapters, but those are no longer part of the
|
||||||
|
* supported stack.
|
||||||
*
|
*
|
||||||
* @deprecated This adapter currently delegates to the deprecated NutJsAutomationAdapter.
|
* @deprecated This adapter should be updated to use Playwright
|
||||||
* Should be updated to use Playwright browser automation when available.
|
* browser automation when available. See docs/ARCHITECTURE.md
|
||||||
* See docs/ARCHITECTURE.md for the updated automation strategy.
|
* for the updated automation strategy.
|
||||||
*/
|
*/
|
||||||
export class AutomationEngineAdapter implements IAutomationEngine {
|
export class AutomationEngineAdapter implements IAutomationEngine {
|
||||||
private isRunning = false;
|
private isRunning = false;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { IAutomationEngine, ValidationResult } from '../../../application/ports/IAutomationEngine';
|
import { IAutomationEngine, ValidationResult } from '../../../../application/ports/IAutomationEngine';
|
||||||
import { HostedSessionConfig } from '../../../domain/entities/HostedSessionConfig';
|
import { HostedSessionConfig } from '../../../../domain/entities/HostedSessionConfig';
|
||||||
import { StepId } from '../../../domain/value-objects/StepId';
|
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||||
import type { IBrowserAutomation } from '../../../application/ports/IScreenAutomation';
|
import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation';
|
||||||
import { ISessionRepository } from '../../../application/ports/ISessionRepository';
|
import { ISessionRepository } from '../../../../application/ports/ISessionRepository';
|
||||||
import { getStepName } from './templates/IRacingTemplateMap';
|
import { getStepName } from './templates/IRacingTemplateMap';
|
||||||
|
|
||||||
export class MockAutomationEngineAdapter implements IAutomationEngine {
|
export class MockAutomationEngineAdapter implements IAutomationEngine {
|
||||||
@@ -68,9 +68,14 @@ export class MockAutomationEngineAdapter implements IAutomationEngine {
|
|||||||
// Execute current step using the browser automation
|
// Execute current step using the browser automation
|
||||||
if (this.browserAutomation.executeStep) {
|
if (this.browserAutomation.executeStep) {
|
||||||
// Use real workflow automation with IRacingSelectorMap
|
// Use real workflow automation with IRacingSelectorMap
|
||||||
const result = await this.browserAutomation.executeStep(currentStep, config as unknown as Record<string, unknown>);
|
const result = await this.browserAutomation.executeStep(
|
||||||
|
currentStep,
|
||||||
|
config as unknown as Record<string, unknown>,
|
||||||
|
);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const errorMessage = `Step ${currentStep.value} (${getStepName(currentStep.value)}) failed: ${result.error}`;
|
const errorMessage = `Step ${currentStep.value} (${getStepName(
|
||||||
|
currentStep.value,
|
||||||
|
)}) failed: ${result.error}`;
|
||||||
console.error(errorMessage);
|
console.error(errorMessage);
|
||||||
|
|
||||||
// Stop automation and mark session as failed
|
// Stop automation and mark session as failed
|
||||||
@@ -95,9 +100,14 @@ export class MockAutomationEngineAdapter implements IAutomationEngine {
|
|||||||
if (nextStep.isFinalStep()) {
|
if (nextStep.isFinalStep()) {
|
||||||
// Execute final step handler
|
// Execute final step handler
|
||||||
if (this.browserAutomation.executeStep) {
|
if (this.browserAutomation.executeStep) {
|
||||||
const result = await this.browserAutomation.executeStep(nextStep, config as unknown as Record<string, unknown>);
|
const result = await this.browserAutomation.executeStep(
|
||||||
|
nextStep,
|
||||||
|
config as unknown as Record<string, unknown>,
|
||||||
|
);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const errorMessage = `Step ${nextStep.value} (${getStepName(nextStep.value)}) failed: ${result.error}`;
|
const errorMessage = `Step ${nextStep.value} (${getStepName(
|
||||||
|
nextStep.value,
|
||||||
|
)}) failed: ${result.error}`;
|
||||||
console.error(errorMessage);
|
console.error(errorMessage);
|
||||||
// Don't try to fail terminal session - just log the error
|
// Don't try to fail terminal session - just log the error
|
||||||
// Session is already in STOPPED_AT_STEP_18 state after transitionToStep()
|
// Session is already in STOPPED_AT_STEP_18 state after transitionToStep()
|
||||||
@@ -118,6 +128,19 @@ export class MockAutomationEngineAdapter implements IAutomationEngine {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Automation error:', error);
|
console.error('Automation error:', error);
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessions = await this.sessionRepository.findAll();
|
||||||
|
const session = sessions[0];
|
||||||
|
if (session && !session.state.isTerminal()) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
session.fail(`Automation error: ${message}`);
|
||||||
|
await this.sessionRepository.update(session);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { StepId } from '../../../domain/value-objects/StepId';
|
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||||
import { HostedSessionConfig } from '../../../domain/entities/HostedSessionConfig';
|
import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation';
|
||||||
import type { IBrowserAutomation } from '../../../application/ports/IScreenAutomation';
|
|
||||||
import {
|
import {
|
||||||
NavigationResult,
|
NavigationResult,
|
||||||
FormFillResult,
|
FormFillResult,
|
||||||
@@ -8,7 +7,7 @@ import {
|
|||||||
WaitResult,
|
WaitResult,
|
||||||
ModalResult,
|
ModalResult,
|
||||||
AutomationResult,
|
AutomationResult,
|
||||||
} from '../../../application/ports/AutomationResults';
|
} from '../../../../application/ports/AutomationResults';
|
||||||
|
|
||||||
interface MockConfig {
|
interface MockConfig {
|
||||||
simulateFailures?: boolean;
|
simulateFailures?: boolean;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* allowing switching between different adapters based on NODE_ENV.
|
* allowing switching between different adapters based on NODE_ENV.
|
||||||
*
|
*
|
||||||
* Mapping:
|
* Mapping:
|
||||||
* - NODE_ENV=production → NutJsAutomationAdapter → iRacing Window → Image Templates
|
* - NODE_ENV=production → real browser automation → iRacing Window → Image Templates
|
||||||
* - NODE_ENV=development → NutJsAutomationAdapter → iRacing Window → Image Templates
|
* - NODE_ENV=development → real browser automation → iRacing Window → Image Templates
|
||||||
* - NODE_ENV=test → MockBrowserAutomation → N/A → N/A
|
* - NODE_ENV=test → MockBrowserAutomation → N/A → N/A
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ export const DEFAULT_TIMING_CONFIG: TimingConfig = {
|
|||||||
export interface AutomationEnvironmentConfig {
|
export interface AutomationEnvironmentConfig {
|
||||||
mode: AutomationMode;
|
mode: AutomationMode;
|
||||||
|
|
||||||
/** Production mode configuration (nut.js) */
|
/** Production/development configuration for native automation */
|
||||||
nutJs?: {
|
nutJs?: {
|
||||||
mouseSpeed?: number;
|
mouseSpeed?: number;
|
||||||
keyboardDelay?: number;
|
keyboardDelay?: number;
|
||||||
@@ -124,7 +124,7 @@ export function getAutomationMode(): AutomationMode {
|
|||||||
* Environment variables:
|
* Environment variables:
|
||||||
* - NODE_ENV: 'production' | 'test' (default: 'test')
|
* - NODE_ENV: 'production' | 'test' (default: 'test')
|
||||||
* - AUTOMATION_MODE: (deprecated) 'dev' | 'production' | 'mock'
|
* - AUTOMATION_MODE: (deprecated) 'dev' | 'production' | 'mock'
|
||||||
* - IRACING_WINDOW_TITLE: Window title for nut.js (default: 'iRacing')
|
* - IRACING_WINDOW_TITLE: Window title for native automation (default: 'iRacing')
|
||||||
* - TEMPLATE_PATH: Path to template images (default: './resources/templates')
|
* - TEMPLATE_PATH: Path to template images (default: './resources/templates')
|
||||||
* - OCR_CONFIDENCE: OCR confidence threshold (default: 0.9)
|
* - OCR_CONFIDENCE: OCR confidence threshold (default: 0.9)
|
||||||
* - AUTOMATION_TIMEOUT: Default timeout in ms (default: 30000)
|
* - AUTOMATION_TIMEOUT: Default timeout in ms (default: 30000)
|
||||||
|
|||||||
@@ -1,396 +1,14 @@
|
|||||||
// Set DISPLAY_SCALE_FACTOR=1 BEFORE any imports that use it
|
import { describe } from 'vitest';
|
||||||
// Templates are already at 2x Retina resolution from macOS screenshot tool
|
|
||||||
// So we don't want to scale them again
|
|
||||||
process.env.DISPLAY_SCALE_FACTOR = '1';
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { spawn, ChildProcess } from 'child_process';
|
|
||||||
import { AutomationSession } from '../../packages/domain/entities/AutomationSession';
|
|
||||||
import { StartAutomationSessionUseCase } from '../../packages/application/use-cases/StartAutomationSessionUseCase';
|
|
||||||
import { InMemorySessionRepository } from '../../packages/infrastructure/repositories/InMemorySessionRepository';
|
|
||||||
import { NutJsAutomationAdapter } from '../../packages/infrastructure/adapters/automation/NutJsAutomationAdapter';
|
|
||||||
import { AutomationEngineAdapter } from '../../packages/infrastructure/adapters/automation/AutomationEngineAdapter';
|
|
||||||
import { NoOpLogAdapter } from '../../packages/infrastructure/adapters/logging/NoOpLogAdapter';
|
|
||||||
import type { IScreenAutomation } from '../../packages/application/ports/IScreenAutomation';
|
|
||||||
import type { ISessionRepository } from '../../packages/application/ports/ISessionRepository';
|
|
||||||
import { StepId } from '../../packages/domain/value-objects/StepId';
|
|
||||||
import { permissionGuard, shouldSkipRealAutomationTests } from './support/PermissionGuard';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E Tests for REAL Automation against REAL iRacing Website
|
* Legacy real automation smoke suite.
|
||||||
*
|
*
|
||||||
* ZERO MOCKS - REAL AUTOMATION against the REAL iRacing website.
|
* Native OS-level automation has been removed.
|
||||||
|
* Real iRacing automation is not currently supported.
|
||||||
*
|
*
|
||||||
* These tests:
|
* This file is retained only as historical documentation and is
|
||||||
* 1. Launch a REAL Chrome browser to https://members-ng.iracing.com/web/racing/hosted/browse-sessions
|
* explicitly skipped so it does not participate in normal E2E runs.
|
||||||
* 2. Use REAL NutJsAutomationAdapter with REAL nut.js mouse/keyboard
|
|
||||||
* 3. Use REAL TemplateMatchingService with REAL OpenCV template matching
|
|
||||||
* 4. Capture REAL screenshots from the ACTUAL display
|
|
||||||
*
|
|
||||||
* Tests will FAIL if:
|
|
||||||
* - Permissions not granted (macOS accessibility/screen recording)
|
|
||||||
* - User is NOT logged into iRacing
|
|
||||||
* - Template images don't match the real iRacing UI
|
|
||||||
* - Real automation cannot execute
|
|
||||||
*
|
|
||||||
* PREREQUISITES:
|
|
||||||
* - User must be logged into iRacing in their default browser
|
|
||||||
* - macOS accessibility and screen recording permissions must be granted
|
|
||||||
*/
|
*/
|
||||||
|
describe.skip('Real automation smoke – REAL iRacing Website (native automation removed)', () => {
|
||||||
const IRACING_URL = 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions';
|
// No-op: native OS-level real automation has been removed.
|
||||||
const WINDOW_TITLE_PATTERN = 'iRacing';
|
});
|
||||||
|
|
||||||
const RUN_REAL_AUTOMATION_SMOKE = process.env.RUN_REAL_AUTOMATION_SMOKE === '1';
|
|
||||||
const describeSmoke = RUN_REAL_AUTOMATION_SMOKE ? describe : describe.skip;
|
|
||||||
|
|
||||||
let skipReason: string | null = null;
|
|
||||||
let browserProcess: ChildProcess | null = null;
|
|
||||||
|
|
||||||
// Configurable wait time for page load (iRacing has heavy JavaScript)
|
|
||||||
const PAGE_LOAD_WAIT_MS = 10000; // 10 seconds for page to fully load
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Launch the DEFAULT browser to iRacing website using macOS `open` command.
|
|
||||||
* This opens the URL in the user's default browser where they are already logged in.
|
|
||||||
*/
|
|
||||||
async function launchBrowserToIRacing(): Promise<{ success: boolean; pid?: number; error?: string }> {
|
|
||||||
// Use macOS `open` command to open URL in the DEFAULT browser
|
|
||||||
// No -a flag = uses the system default browser
|
|
||||||
browserProcess = spawn('open', [IRACING_URL], {
|
|
||||||
detached: false,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for browser to start and page to fully load
|
|
||||||
// iRacing's pages have heavy JavaScript that takes time to render
|
|
||||||
console.log(`⏳ Waiting ${PAGE_LOAD_WAIT_MS / 1000} seconds for iRacing page to fully load...`);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, PAGE_LOAD_WAIT_MS));
|
|
||||||
console.log('✓ Page load wait complete');
|
|
||||||
|
|
||||||
// The `open` command returns immediately
|
|
||||||
if (browserProcess.pid) {
|
|
||||||
return { success: true, pid: browserProcess.pid };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, error: 'Failed to open default browser' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the browser window.
|
|
||||||
* Note: Since we use `open` command, we can't directly kill the browser.
|
|
||||||
* The browser will remain open after tests complete.
|
|
||||||
*/
|
|
||||||
async function closeBrowser(): Promise<void> {
|
|
||||||
// The `open` command spawns a separate process, so browserProcess
|
|
||||||
// is just the `open` command itself, not the browser.
|
|
||||||
// We leave the browser open so the user can inspect the state.
|
|
||||||
browserProcess = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
describeSmoke('Real automation smoke – REAL iRacing Website', () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
// Check permissions first
|
|
||||||
skipReason = await shouldSkipRealAutomationTests() ?? null;
|
|
||||||
|
|
||||||
if (skipReason) {
|
|
||||||
console.warn('\n⚠️ E2E tests will be skipped due to:', skipReason);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n✓ Permissions verified - ready for REAL automation tests');
|
|
||||||
|
|
||||||
// Launch browser to REAL iRacing website
|
|
||||||
console.log('🏎️ Launching Chrome browser to REAL iRacing website...');
|
|
||||||
console.log(`🌐 URL: ${IRACING_URL}`);
|
|
||||||
|
|
||||||
const launchResult = await launchBrowserToIRacing();
|
|
||||||
|
|
||||||
if (!launchResult.success) {
|
|
||||||
skipReason = `Failed to launch browser: ${launchResult.error}`;
|
|
||||||
console.warn(`\n⚠️ ${skipReason}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✓ Browser launched (PID: ${launchResult.pid})`);
|
|
||||||
console.log('⚠️ IMPORTANT: You must be logged into iRacing for tests to work!');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (browserProcess) {
|
|
||||||
console.log('\n🛑 Closing browser...');
|
|
||||||
await closeBrowser();
|
|
||||||
console.log('✓ Browser closed');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Permission and Environment Checks', () => {
|
|
||||||
it('should verify permission status', async () => {
|
|
||||||
const result = await permissionGuard.checkPermissions();
|
|
||||||
|
|
||||||
console.log('\n📋 Permission Status:');
|
|
||||||
console.log(permissionGuard.formatStatus(result.status));
|
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result.status).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Real Automation against REAL iRacing', () => {
|
|
||||||
let sessionRepository: ISessionRepository;
|
|
||||||
let screenAutomation: IScreenAutomation;
|
|
||||||
let startAutomationUseCase: StartAutomationSessionUseCase;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
if (skipReason) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create real session repository
|
|
||||||
sessionRepository = new InMemorySessionRepository();
|
|
||||||
|
|
||||||
// Create REAL nut.js adapter - NO MOCKS
|
|
||||||
// Using shorter timeouts for E2E tests to fail fast when templates don't match
|
|
||||||
const logger = new NoOpLogAdapter();
|
|
||||||
const nutJsAdapter = new NutJsAutomationAdapter(
|
|
||||||
{
|
|
||||||
windowTitle: WINDOW_TITLE_PATTERN,
|
|
||||||
templatePath: './resources/templates/iracing',
|
|
||||||
defaultTimeout: 5000, // 5 seconds max per operation
|
|
||||||
mouseSpeed: 500,
|
|
||||||
keyboardDelay: 30,
|
|
||||||
retry: {
|
|
||||||
maxRetries: 1, // Only 1 retry in E2E tests for faster feedback
|
|
||||||
baseDelayMs: 200,
|
|
||||||
maxDelayMs: 1000,
|
|
||||||
backoffMultiplier: 1.5,
|
|
||||||
},
|
|
||||||
timing: {
|
|
||||||
pageLoadWaitMs: 2000,
|
|
||||||
interActionDelayMs: 100,
|
|
||||||
postClickDelayMs: 200,
|
|
||||||
preStepDelayMs: 50,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logger
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use the REAL adapter directly
|
|
||||||
screenAutomation = nutJsAdapter;
|
|
||||||
|
|
||||||
// Create REAL automation engine
|
|
||||||
const automationEngine = new AutomationEngineAdapter(
|
|
||||||
screenAutomation,
|
|
||||||
sessionRepository
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create use case
|
|
||||||
startAutomationUseCase = new StartAutomationSessionUseCase(
|
|
||||||
automationEngine,
|
|
||||||
screenAutomation,
|
|
||||||
sessionRepository
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
if (screenAutomation && 'disconnect' in screenAutomation) {
|
|
||||||
try {
|
|
||||||
await (screenAutomation as NutJsAutomationAdapter).disconnect();
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should connect to REAL screen automation', async () => {
|
|
||||||
if (skipReason) {
|
|
||||||
console.log(`⏭️ Skipped: ${skipReason}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectResult = await screenAutomation.connect?.();
|
|
||||||
|
|
||||||
console.log('\n🔌 Connect Result:', connectResult);
|
|
||||||
|
|
||||||
expect(connectResult).toBeDefined();
|
|
||||||
|
|
||||||
if (!connectResult?.success) {
|
|
||||||
throw new Error(
|
|
||||||
`REAL SCREEN AUTOMATION FAILED TO CONNECT.\n` +
|
|
||||||
`Error: ${connectResult?.error}\n` +
|
|
||||||
`This requires macOS accessibility and screen recording permissions.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✓ REAL screen automation connected successfully');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should capture REAL screen showing iRacing website', async () => {
|
|
||||||
if (skipReason) {
|
|
||||||
console.log(`⏭️ Skipped: ${skipReason}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectResult = await screenAutomation.connect?.();
|
|
||||||
if (!connectResult?.success) {
|
|
||||||
throw new Error(`Failed to connect: ${connectResult?.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const captureResult = await screenAutomation.captureScreen?.();
|
|
||||||
|
|
||||||
console.log('\n📸 Screen Capture Result:', {
|
|
||||||
success: captureResult?.success,
|
|
||||||
hasData: !!captureResult?.data,
|
|
||||||
dataLength: captureResult?.data?.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(captureResult).toBeDefined();
|
|
||||||
|
|
||||||
if (!captureResult?.success) {
|
|
||||||
throw new Error(
|
|
||||||
`REAL SCREEN CAPTURE FAILED.\n` +
|
|
||||||
`Error: ${captureResult?.error}\n` +
|
|
||||||
`The iRacing website should be visible in Chrome.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(captureResult.data).toBeDefined();
|
|
||||||
expect(captureResult.data?.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
console.log(`✓ REAL screen captured: ${captureResult.data?.length} bytes`);
|
|
||||||
console.log(' This screenshot contains the REAL iRacing website!');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should focus the iRacing browser window', async () => {
|
|
||||||
if (skipReason) {
|
|
||||||
console.log(`⏭️ Skipped: ${skipReason}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectResult = await screenAutomation.connect?.();
|
|
||||||
if (!connectResult?.success) {
|
|
||||||
throw new Error(`Failed to connect: ${connectResult?.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n🔍 Attempting to focus iRacing browser window...');
|
|
||||||
|
|
||||||
const focusResult = await screenAutomation.focusBrowserWindow?.(WINDOW_TITLE_PATTERN);
|
|
||||||
|
|
||||||
console.log('Focus Result:', focusResult);
|
|
||||||
|
|
||||||
// This may fail if iRacing window doesn't have expected title
|
|
||||||
if (!focusResult?.success) {
|
|
||||||
console.log(`⚠️ Could not focus window: ${focusResult?.error}`);
|
|
||||||
console.log(' Make sure iRacing website is displayed in Chrome');
|
|
||||||
} else {
|
|
||||||
console.log('✓ Browser window focused');
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(focusResult).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should attempt REAL step 2 automation against iRacing website', async () => {
|
|
||||||
if (skipReason) {
|
|
||||||
console.log(`⏭️ Skipped: ${skipReason}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectResult = await screenAutomation.connect?.();
|
|
||||||
if (!connectResult?.success) {
|
|
||||||
throw new Error(`Failed to connect: ${connectResult?.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stepId = StepId.create(2);
|
|
||||||
const config = {
|
|
||||||
sessionName: 'E2E Test Race',
|
|
||||||
trackId: 'spa',
|
|
||||||
carIds: ['dallara-f3'],
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('\n🏎️ Executing REAL step 2 automation against iRacing website...');
|
|
||||||
console.log(' This uses REAL nut.js + OpenCV template matching');
|
|
||||||
console.log(' Looking for UI elements in the REAL iRacing website');
|
|
||||||
|
|
||||||
const result = await screenAutomation.executeStep?.(stepId, config);
|
|
||||||
|
|
||||||
console.log('\n📊 Step Execution Result:', result);
|
|
||||||
|
|
||||||
// We EXPECT this to either:
|
|
||||||
// 1. FAIL because templates don't match the real iRacing UI
|
|
||||||
// 2. FAIL because user is not logged in
|
|
||||||
// 3. SUCCEED if templates match and user is logged in
|
|
||||||
|
|
||||||
if (result?.success) {
|
|
||||||
console.log('\n✓ STEP 2 SUCCEEDED!');
|
|
||||||
console.log(' Real automation found and clicked UI elements in iRacing!');
|
|
||||||
} else {
|
|
||||||
console.log(`\n✗ Step 2 failed: ${result?.error}`);
|
|
||||||
console.log(' This is expected if:');
|
|
||||||
console.log(' - User is NOT logged into iRacing');
|
|
||||||
console.log(' - Templates don\'t match the real iRacing UI');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test passes - we verified real automation was attempted
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should execute REAL workflow against iRacing website', async () => {
|
|
||||||
if (skipReason) {
|
|
||||||
console.log(`⏭️ Skipped: ${skipReason}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectResult = await screenAutomation.connect?.();
|
|
||||||
if (!connectResult?.success) {
|
|
||||||
throw new Error(`Failed to connect: ${connectResult?.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionConfig = {
|
|
||||||
sessionName: 'E2E Test Race',
|
|
||||||
trackId: 'spa',
|
|
||||||
carIds: ['dallara-f3'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const session = AutomationSession.create(sessionConfig);
|
|
||||||
session.start();
|
|
||||||
await sessionRepository.save(session);
|
|
||||||
|
|
||||||
console.log('\n🏎️ Starting REAL workflow automation against iRacing website...');
|
|
||||||
console.log(' Each step uses REAL template matching and mouse/keyboard');
|
|
||||||
console.log(' Against the REAL iRacing website in Chrome');
|
|
||||||
|
|
||||||
const stepResults: Array<{ step: number; success: boolean; error?: string }> = [];
|
|
||||||
|
|
||||||
for (let step = 2; step <= 5; step++) {
|
|
||||||
console.log(`\n → Attempting REAL step ${step}...`);
|
|
||||||
|
|
||||||
const result = await screenAutomation.executeStep?.(StepId.create(step), sessionConfig);
|
|
||||||
stepResults.push({
|
|
||||||
step,
|
|
||||||
success: result?.success ?? false,
|
|
||||||
error: result?.error,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(` Result: ${result?.success ? '✓' : '✗'} ${result?.error || 'Success'}`);
|
|
||||||
|
|
||||||
if (!result?.success) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n📊 Workflow Results:', stepResults);
|
|
||||||
|
|
||||||
const allSucceeded = stepResults.every(r => r.success);
|
|
||||||
const failedStep = stepResults.find(r => !r.success);
|
|
||||||
|
|
||||||
if (allSucceeded) {
|
|
||||||
console.log('\n✓ ALL STEPS SUCCEEDED against iRacing website!');
|
|
||||||
} else {
|
|
||||||
console.log(`\n✗ Workflow stopped at step ${failedStep?.step}: ${failedStep?.error}`);
|
|
||||||
console.log(' This is expected if user is not logged in or templates need updating');
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(stepResults.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,737 +1,8 @@
|
|||||||
import { Given, When, Then, Before, After, BeforeAll, AfterAll } from '@cucumber/cucumber';
|
|
||||||
import { expect } from 'vitest';
|
|
||||||
import { spawn, ChildProcess } from 'child_process';
|
|
||||||
import { existsSync } from 'fs';
|
|
||||||
import { AutomationSession } from '../../../packages/domain/entities/AutomationSession';
|
|
||||||
import { StartAutomationSessionUseCase } from '../../../packages/application/use-cases/StartAutomationSessionUseCase';
|
|
||||||
import { InMemorySessionRepository } from '../../../packages/infrastructure/repositories/InMemorySessionRepository';
|
|
||||||
import { NutJsAutomationAdapter } from '../../../packages/infrastructure/adapters/automation/NutJsAutomationAdapter';
|
|
||||||
import { AutomationEngineAdapter } from '../../../packages/infrastructure/adapters/automation/AutomationEngineAdapter';
|
|
||||||
import { NoOpLogAdapter } from '../../../packages/infrastructure/adapters/logging/NoOpLogAdapter';
|
|
||||||
import type { IScreenAutomation } from '../../../packages/application/ports/IScreenAutomation';
|
|
||||||
import type { ISessionRepository } from '../../../packages/application/ports/ISessionRepository';
|
|
||||||
import { StepId } from '../../../packages/domain/value-objects/StepId';
|
|
||||||
import { permissionGuard, shouldSkipRealAutomationTests } from '../support/PermissionGuard';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E Test Context - REAL iRacing Website Automation
|
* Legacy Cucumber step definitions for real iRacing automation.
|
||||||
*
|
*
|
||||||
* ZERO MOCKS. ZERO FIXTURES. ZERO FAKE HTML PAGES.
|
* Native OS-level automation and these steps have been retired.
|
||||||
*
|
* This file is excluded from TypeScript builds and is kept only as
|
||||||
* Uses 100% REAL adapters against the REAL iRacing website:
|
* historical documentation. No executable step definitions remain.
|
||||||
* - NutJsAutomationAdapter: REAL nut.js mouse/keyboard automation
|
|
||||||
* - Real screen capture and template matching via OpenCV
|
|
||||||
* - InMemorySessionRepository: Real in-memory persistence (not a mock)
|
|
||||||
* - REAL Chrome browser pointing to https://members.iracing.com
|
|
||||||
*
|
|
||||||
* These tests run against the REAL iRacing website and require:
|
|
||||||
* - macOS with display access
|
|
||||||
* - Accessibility permissions for nut.js
|
|
||||||
* - Screen Recording permissions
|
|
||||||
* - User must be LOGGED INTO iRacing (tests cannot automate login)
|
|
||||||
*
|
|
||||||
* If these requirements are not met, tests will be SKIPPED gracefully.
|
|
||||||
*/
|
*/
|
||||||
interface TestContext {
|
export {};
|
||||||
sessionRepository: ISessionRepository;
|
|
||||||
screenAutomation: IScreenAutomation;
|
|
||||||
startAutomationUseCase: StartAutomationSessionUseCase;
|
|
||||||
currentSession: AutomationSession | null;
|
|
||||||
sessionConfig: Record<string, unknown>;
|
|
||||||
error: Error | null;
|
|
||||||
startTime: number;
|
|
||||||
skipReason: string | null;
|
|
||||||
stepResults: Array<{ step: number; success: boolean; error?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Real iRacing website URL.
|
|
||||||
*/
|
|
||||||
const IRACING_URL = 'https://members.iracing.com/membersite/member/Series.do';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Window title pattern for real iRacing browser.
|
|
||||||
*/
|
|
||||||
const IRACING_WINDOW_TITLE = 'iRacing';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global state for browser and skip reason (shared across scenarios)
|
|
||||||
*/
|
|
||||||
let globalBrowserProcess: ChildProcess | null = null;
|
|
||||||
let globalSkipReason: string | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find Chrome executable path.
|
|
||||||
*/
|
|
||||||
function findChromePath(): string | null {
|
|
||||||
const paths = [
|
|
||||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
||||||
'/usr/bin/google-chrome',
|
|
||||||
'/usr/bin/google-chrome-stable',
|
|
||||||
'/usr/bin/chromium',
|
|
||||||
'/usr/bin/chromium-browser',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const path of paths) {
|
|
||||||
if (existsSync(path)) {
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Launch real Chrome browser to iRacing website.
|
|
||||||
*/
|
|
||||||
async function launchBrowserToIRacing(): Promise<{ success: boolean; pid?: number; error?: string }> {
|
|
||||||
const chromePath = findChromePath();
|
|
||||||
|
|
||||||
if (!chromePath) {
|
|
||||||
return { success: false, error: 'Chrome not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
'--disable-extensions',
|
|
||||||
'--disable-plugins',
|
|
||||||
'--window-position=0,0',
|
|
||||||
'--window-size=1920,1080',
|
|
||||||
'--no-first-run',
|
|
||||||
'--no-default-browser-check',
|
|
||||||
IRACING_URL,
|
|
||||||
];
|
|
||||||
|
|
||||||
globalBrowserProcess = spawn(chromePath, args, {
|
|
||||||
detached: false,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for browser to start
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
||||||
|
|
||||||
if (globalBrowserProcess.pid) {
|
|
||||||
return { success: true, pid: globalBrowserProcess.pid };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, error: 'Browser process failed to start' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the browser process.
|
|
||||||
*/
|
|
||||||
async function closeBrowser(): Promise<void> {
|
|
||||||
if (!globalBrowserProcess) return;
|
|
||||||
|
|
||||||
return new Promise(resolve => {
|
|
||||||
if (!globalBrowserProcess) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
globalBrowserProcess.once('exit', () => {
|
|
||||||
globalBrowserProcess = null;
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
globalBrowserProcess.kill('SIGTERM');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (globalBrowserProcess) {
|
|
||||||
globalBrowserProcess.kill('SIGKILL');
|
|
||||||
globalBrowserProcess = null;
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BeforeAll: Check permissions and launch real browser to iRacing.
|
|
||||||
*/
|
|
||||||
BeforeAll(async function () {
|
|
||||||
globalSkipReason = await shouldSkipRealAutomationTests() ?? null;
|
|
||||||
|
|
||||||
if (globalSkipReason) {
|
|
||||||
console.warn('\n⚠️ E2E tests will be SKIPPED due to permission/environment issues');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n✓ Permissions verified - ready for REAL automation tests');
|
|
||||||
console.log('🏎️ Launching REAL Chrome browser to iRacing website...');
|
|
||||||
|
|
||||||
// Launch real browser to real iRacing website
|
|
||||||
const launchResult = await launchBrowserToIRacing();
|
|
||||||
|
|
||||||
if (!launchResult.success) {
|
|
||||||
globalSkipReason = `Failed to launch browser: ${launchResult.error}`;
|
|
||||||
console.warn(`\n⚠️ ${globalSkipReason}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✓ Browser launched (PID: ${launchResult.pid})`);
|
|
||||||
console.log(`🌐 Navigated to: ${IRACING_URL}`);
|
|
||||||
console.log('⚠️ IMPORTANT: You must be logged into iRacing for tests to work!');
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AfterAll: Close browser.
|
|
||||||
*/
|
|
||||||
AfterAll(async function () {
|
|
||||||
if (globalBrowserProcess) {
|
|
||||||
console.log('\n🛑 Closing browser...');
|
|
||||||
await closeBrowser();
|
|
||||||
console.log('✓ Browser closed');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Before(async function (this: TestContext) {
|
|
||||||
this.skipReason = globalSkipReason;
|
|
||||||
|
|
||||||
if (this.skipReason) {
|
|
||||||
return 'skipped';
|
|
||||||
}
|
|
||||||
|
|
||||||
process.env.NODE_ENV = 'test';
|
|
||||||
|
|
||||||
// Create real session repository
|
|
||||||
this.sessionRepository = new InMemorySessionRepository();
|
|
||||||
|
|
||||||
// Create REAL nut.js automation adapter - NO MOCKS
|
|
||||||
const logger = new NoOpLogAdapter();
|
|
||||||
const nutJsAdapter = new NutJsAutomationAdapter(
|
|
||||||
{
|
|
||||||
windowTitle: IRACING_WINDOW_TITLE,
|
|
||||||
templatePath: './resources/templates/iracing',
|
|
||||||
defaultTimeout: 10000,
|
|
||||||
mouseSpeed: 500,
|
|
||||||
keyboardDelay: 30,
|
|
||||||
},
|
|
||||||
logger
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use the REAL adapter directly
|
|
||||||
this.screenAutomation = nutJsAdapter;
|
|
||||||
|
|
||||||
// Connect to REAL automation
|
|
||||||
const connectResult = await this.screenAutomation.connect?.();
|
|
||||||
if (connectResult && !connectResult.success) {
|
|
||||||
this.skipReason = `Failed to connect REAL automation: ${connectResult.error}`;
|
|
||||||
return 'skipped';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create REAL automation engine
|
|
||||||
const automationEngine = new AutomationEngineAdapter(
|
|
||||||
this.screenAutomation,
|
|
||||||
this.sessionRepository
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create use case with REAL adapters
|
|
||||||
this.startAutomationUseCase = new StartAutomationSessionUseCase(
|
|
||||||
automationEngine,
|
|
||||||
this.screenAutomation,
|
|
||||||
this.sessionRepository
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initialize test state
|
|
||||||
this.currentSession = null;
|
|
||||||
this.sessionConfig = {};
|
|
||||||
this.error = null;
|
|
||||||
this.startTime = 0;
|
|
||||||
this.stepResults = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
After(async function (this: TestContext) {
|
|
||||||
// Log step results if any
|
|
||||||
if (this.stepResults && this.stepResults.length > 0) {
|
|
||||||
console.log('\n📊 Step Execution Results:');
|
|
||||||
this.stepResults.forEach(r => {
|
|
||||||
console.log(` Step ${r.step}: ${r.success ? '✓' : '✗'} ${r.error || ''}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disconnect REAL automation adapter
|
|
||||||
if (this.screenAutomation && 'disconnect' in this.screenAutomation) {
|
|
||||||
try {
|
|
||||||
await (this.screenAutomation as NutJsAutomationAdapter).disconnect();
|
|
||||||
} catch {
|
|
||||||
// Ignore disconnect errors during cleanup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset test state
|
|
||||||
this.currentSession = null;
|
|
||||||
this.sessionConfig = {};
|
|
||||||
this.error = null;
|
|
||||||
this.stepResults = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
Given('the companion app is running', function (this: TestContext) {
|
|
||||||
expect(this.screenAutomation).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
Given('I am authenticated with iRacing', function (this: TestContext) {
|
|
||||||
// In REAL E2E tests, user must already be logged into iRacing
|
|
||||||
// We cannot automate login for security reasons
|
|
||||||
console.log('⚠️ Assuming user is logged into iRacing - tests will fail if not');
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
Given('I have a valid session configuration', function (this: TestContext) {
|
|
||||||
this.sessionConfig = {
|
|
||||||
sessionName: 'Test Race Session',
|
|
||||||
trackId: 'spa',
|
|
||||||
carIds: ['dallara-f3'],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
Given('I have a session configuration with:', function (this: TestContext, dataTable: any) {
|
|
||||||
const rows = dataTable.rawTable.slice(1);
|
|
||||||
this.sessionConfig = {};
|
|
||||||
|
|
||||||
rows.forEach(([field, value]: [string, string]) => {
|
|
||||||
if (field === 'carIds') {
|
|
||||||
this.sessionConfig[field] = value.split(',').map(v => v.trim());
|
|
||||||
} else {
|
|
||||||
this.sessionConfig[field] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Given('I have started an automation session', async function (this: TestContext) {
|
|
||||||
this.sessionConfig = {
|
|
||||||
sessionName: 'Test Race',
|
|
||||||
trackId: 'spa',
|
|
||||||
carIds: ['dallara-f3'],
|
|
||||||
};
|
|
||||||
|
|
||||||
this.currentSession = AutomationSession.create(this.sessionConfig);
|
|
||||||
this.currentSession.start();
|
|
||||||
await this.sessionRepository.save(this.currentSession);
|
|
||||||
});
|
|
||||||
|
|
||||||
Given('the automation has reached step {int}', async function (this: TestContext, stepNumber: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
|
|
||||||
for (let i = 2; i <= stepNumber; i++) {
|
|
||||||
this.currentSession!.transitionToStep(StepId.create(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sessionRepository.update(this.currentSession!);
|
|
||||||
});
|
|
||||||
|
|
||||||
Given('the automation has progressed to step {int}', async function (this: TestContext, stepNumber: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
|
|
||||||
for (let i = 2; i <= stepNumber; i++) {
|
|
||||||
this.currentSession!.transitionToStep(StepId.create(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sessionRepository.update(this.currentSession!);
|
|
||||||
});
|
|
||||||
|
|
||||||
Given('the automation is at step {int}', async function (this: TestContext, stepNumber: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
|
|
||||||
for (let i = 2; i <= stepNumber; i++) {
|
|
||||||
this.currentSession!.transitionToStep(StepId.create(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sessionRepository.update(this.currentSession!);
|
|
||||||
});
|
|
||||||
|
|
||||||
Given('the session is in progress', function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.state.isInProgress()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
When('I start the automation session', async function (this: TestContext) {
|
|
||||||
try {
|
|
||||||
const result = await this.startAutomationUseCase.execute(this.sessionConfig);
|
|
||||||
this.currentSession = await this.sessionRepository.findById(result.sessionId);
|
|
||||||
this.startTime = Date.now();
|
|
||||||
} catch (error) {
|
|
||||||
this.error = error as Error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
When('I attempt to start the automation session', async function (this: TestContext) {
|
|
||||||
try {
|
|
||||||
const result = await this.startAutomationUseCase.execute(this.sessionConfig);
|
|
||||||
this.currentSession = await this.sessionRepository.findById(result.sessionId);
|
|
||||||
} catch (error) {
|
|
||||||
this.error = error as Error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
When('the automation progresses through all {int} steps', async function (this: TestContext, stepCount: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
this.currentSession!.start();
|
|
||||||
|
|
||||||
console.log('\n🏎️ Executing REAL automation workflow against iRacing...');
|
|
||||||
console.log(' Each step uses REAL template matching and mouse/keyboard');
|
|
||||||
console.log(' Against the REAL iRacing website in Chrome');
|
|
||||||
|
|
||||||
// Execute each step with REAL automation
|
|
||||||
for (let i = 2; i <= stepCount; i++) {
|
|
||||||
console.log(`\n → Executing REAL step ${i}...`);
|
|
||||||
|
|
||||||
// Execute the step using REAL automation (nut.js + template matching)
|
|
||||||
const result = await this.screenAutomation.executeStep?.(StepId.create(i), this.sessionConfig);
|
|
||||||
|
|
||||||
this.stepResults.push({
|
|
||||||
step: i,
|
|
||||||
success: result?.success ?? false,
|
|
||||||
error: result?.error,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(` Result: ${result?.success ? '✓' : '✗'} ${result?.error || 'Success'}`);
|
|
||||||
|
|
||||||
if (result && !result.success) {
|
|
||||||
// REAL automation failed - this is expected if iRacing isn't properly set up
|
|
||||||
throw new Error(
|
|
||||||
`REAL automation failed at step ${i}: ${result.error}\n` +
|
|
||||||
`This test requires iRacing to be logged in and on the correct page.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentSession!.transitionToStep(StepId.create(i));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
When('the automation transitions to step {int}', async function (this: TestContext, stepNumber: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
|
|
||||||
// Execute REAL automation for this step
|
|
||||||
console.log(`\n → Executing REAL step ${stepNumber}...`);
|
|
||||||
const result = await this.screenAutomation.executeStep?.(StepId.create(stepNumber), this.sessionConfig);
|
|
||||||
|
|
||||||
this.stepResults.push({
|
|
||||||
step: stepNumber,
|
|
||||||
success: result?.success ?? false,
|
|
||||||
error: result?.error,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result && !result.success) {
|
|
||||||
console.log(` REAL automation failed: ${result.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentSession!.transitionToStep(StepId.create(stepNumber));
|
|
||||||
await this.sessionRepository.update(this.currentSession!);
|
|
||||||
});
|
|
||||||
|
|
||||||
When('the {string} modal appears', async function (this: TestContext, modalName: string) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.isAtModalStep()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
When('I pause the automation', async function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
this.currentSession!.pause();
|
|
||||||
await this.sessionRepository.update(this.currentSession!);
|
|
||||||
});
|
|
||||||
|
|
||||||
When('I resume the automation', async function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
this.currentSession!.resume();
|
|
||||||
await this.sessionRepository.update(this.currentSession!);
|
|
||||||
});
|
|
||||||
|
|
||||||
When('a browser automation error occurs', async function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
this.currentSession!.fail('Browser automation failed at step 8');
|
|
||||||
await this.sessionRepository.update(this.currentSession!);
|
|
||||||
});
|
|
||||||
|
|
||||||
When('I attempt to skip directly to step {int}', function (this: TestContext, targetStep: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.currentSession!.transitionToStep(StepId.create(targetStep));
|
|
||||||
} catch (error) {
|
|
||||||
this.error = error as Error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
When('I attempt to move back to step {int}', function (this: TestContext, targetStep: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.currentSession!.transitionToStep(StepId.create(targetStep));
|
|
||||||
} catch (error) {
|
|
||||||
this.error = error as Error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
When('the automation reaches step {int}', async function (this: TestContext, stepNumber: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
|
|
||||||
for (let i = 2; i <= stepNumber; i++) {
|
|
||||||
this.currentSession!.transitionToStep(StepId.create(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sessionRepository.update(this.currentSession!);
|
|
||||||
});
|
|
||||||
|
|
||||||
When('the application restarts', function (this: TestContext) {
|
|
||||||
const sessionId = this.currentSession!.id;
|
|
||||||
this.currentSession = null;
|
|
||||||
|
|
||||||
this.sessionRepository.findById(sessionId).then(session => {
|
|
||||||
this.currentSession = session;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
When('I attempt to start another automation session', async function (this: TestContext) {
|
|
||||||
const newConfig = {
|
|
||||||
sessionName: 'Second Race',
|
|
||||||
trackId: 'monza',
|
|
||||||
carIds: ['porsche-911-gt3'],
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.startAutomationUseCase.execute(newConfig);
|
|
||||||
} catch (error) {
|
|
||||||
this.error = error as Error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
When('the automation runs for {int} seconds', async function (this: TestContext, seconds: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
|
|
||||||
});
|
|
||||||
|
|
||||||
When('I query the session status', async function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
const retrieved = await this.sessionRepository.findById(this.currentSession!.id);
|
|
||||||
this.currentSession = retrieved;
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the session should be created with state {string}', function (this: TestContext, expectedState: string) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.state.value).toBe(expectedState);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the current step should be {int}', function (this: TestContext, expectedStep: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.currentStep.value).toBe(expectedStep);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the current step should remain {int}', function (this: TestContext, expectedStep: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.currentStep.value).toBe(expectedStep);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('step {int} should navigate to {string}', function (this: TestContext, stepNumber: number, description: string) {
|
|
||||||
expect(stepNumber).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(stepNumber).toBeLessThanOrEqual(18);
|
|
||||||
|
|
||||||
const stepResult = this.stepResults.find(r => r.step === stepNumber);
|
|
||||||
if (!stepResult) {
|
|
||||||
console.log(`⚠️ Step ${stepNumber} (${description}) was not executed - REAL automation required`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('step {int} should click {string}', function (this: TestContext, stepNumber: number, description: string) {
|
|
||||||
expect(stepNumber).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(stepNumber).toBeLessThanOrEqual(18);
|
|
||||||
|
|
||||||
const stepResult = this.stepResults.find(r => r.step === stepNumber);
|
|
||||||
if (!stepResult) {
|
|
||||||
console.log(`⚠️ Step ${stepNumber} (${description}) click was not executed - REAL automation required`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('step {int} should fill {string}', function (this: TestContext, stepNumber: number, description: string) {
|
|
||||||
expect(stepNumber).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(stepNumber).toBeLessThanOrEqual(18);
|
|
||||||
|
|
||||||
const stepResult = this.stepResults.find(r => r.step === stepNumber);
|
|
||||||
if (!stepResult) {
|
|
||||||
console.log(`⚠️ Step ${stepNumber} (${description}) fill was not executed - REAL automation required`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('step {int} should configure {string}', function (this: TestContext, stepNumber: number, description: string) {
|
|
||||||
expect(stepNumber).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(stepNumber).toBeLessThanOrEqual(18);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('step {int} should access {string}', function (this: TestContext, stepNumber: number, description: string) {
|
|
||||||
expect(stepNumber).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(stepNumber).toBeLessThanOrEqual(18);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('step {int} should handle {string} modal', function (this: TestContext, stepNumber: number, modalName: string) {
|
|
||||||
expect([6, 9, 12]).toContain(stepNumber);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('step {int} should set {string}', function (this: TestContext, stepNumber: number, description: string) {
|
|
||||||
expect(stepNumber).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(stepNumber).toBeLessThanOrEqual(18);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('step {int} should reach {string}', function (this: TestContext, stepNumber: number, description: string) {
|
|
||||||
expect(stepNumber).toBe(18);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the session should stop at step {int}', function (this: TestContext, expectedStep: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.currentStep.value).toBe(expectedStep);
|
|
||||||
expect(this.currentSession!.state.isStoppedAtStep18()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the session state should be {string}', function (this: TestContext, expectedState: string) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.state.value).toBe(expectedState);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('a manual submit warning should be displayed', function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.currentStep.isFinalStep()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the automation should detect the modal', function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.isAtModalStep()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the automation should wait for modal content to load', async function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the automation should fill admin fields', async function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the automation should close the modal', async function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the automation should transition to step {int}', async function (this: TestContext, nextStep: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
this.currentSession!.transitionToStep(StepId.create(nextStep));
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the automation should select the car {string}', async function (this: TestContext, carId: string) {
|
|
||||||
expect(this.sessionConfig.carIds).toContain(carId);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the automation should confirm the selection', async function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the automation should select the track {string}', async function (this: TestContext, trackId: string) {
|
|
||||||
expect(this.sessionConfig.trackId).toBe(trackId);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the automation should automatically stop', function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.state.isStoppedAtStep18()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('no submit action should be executed', function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.state.isStoppedAtStep18()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('a notification should inform the user to review before submitting', function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.currentStep.isFinalStep()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the automation should continue from step {int}', function (this: TestContext, expectedStep: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.currentStep.value).toBe(expectedStep);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('an error message should be recorded', function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.errorMessage).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the session should have a completedAt timestamp', function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.completedAt).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the user should be notified of the failure', function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.state.isFailed()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the session creation should fail', function (this: TestContext) {
|
|
||||||
expect(this.error).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('an error message should indicate {string}', function (this: TestContext, expectedMessage: string) {
|
|
||||||
expect(this.error).toBeDefined();
|
|
||||||
expect(this.error!.message).toContain(expectedMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('no session should be persisted', async function (this: TestContext) {
|
|
||||||
const sessions = await this.sessionRepository.findAll();
|
|
||||||
expect(sessions).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the transition should be rejected', function (this: TestContext) {
|
|
||||||
expect(this.error).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('all three cars should be added via the modal', function (this: TestContext) {
|
|
||||||
expect(this.sessionConfig.carIds).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the automation should handle the modal three times', function (this: TestContext) {
|
|
||||||
expect(this.sessionConfig.carIds).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the session should be recoverable from storage', async function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the session configuration should be intact', function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.config).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the second session creation should be queued or rejected', function (this: TestContext) {
|
|
||||||
expect(this.error).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('a warning should inform about the active session', function (this: TestContext) {
|
|
||||||
expect(this.error).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the elapsed time should be approximately {int} milliseconds', function (this: TestContext, expectedMs: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
const elapsed = this.currentSession!.getElapsedTime();
|
|
||||||
expect(elapsed).toBeGreaterThanOrEqual(expectedMs - 1000);
|
|
||||||
expect(elapsed).toBeLessThanOrEqual(expectedMs + 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the elapsed time should increase while in progress', function (this: TestContext) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
const elapsed = this.currentSession!.getElapsedTime();
|
|
||||||
expect(elapsed).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('each step should take between {int}ms and {int}ms', function (this: TestContext, minMs: number, maxMs: number) {
|
|
||||||
expect(minMs).toBeLessThan(maxMs);
|
|
||||||
// In REAL automation, timing depends on actual screen/mouse operations
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('modal steps should take longer than regular steps', function (this: TestContext) {
|
|
||||||
// In REAL automation, modal steps involve more operations
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the total workflow should complete in under {int} seconds', function (this: TestContext, maxSeconds: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
const elapsed = this.currentSession!.getElapsedTime();
|
|
||||||
expect(elapsed).toBeLessThan(maxSeconds * 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the session should stop at step {int} without submitting', function (this: TestContext, expectedStep: number) {
|
|
||||||
expect(this.currentSession).toBeDefined();
|
|
||||||
expect(this.currentSession!.currentStep.value).toBe(expectedStep);
|
|
||||||
expect(this.currentSession!.state.isStoppedAtStep18()).toBe(true);
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import type { StepHarness } from '../support/StepHarness';
|
import type { StepHarness } from '../support/StepHarness';
|
||||||
import { createStepHarness } from '../support/StepHarness';
|
import { createStepHarness } from '../support/StepHarness';
|
||||||
|
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||||
|
|
||||||
describe('Step 2 – create race', () => {
|
describe('Step 2 – create race', () => {
|
||||||
let harness: StepHarness;
|
let harness: StepHarness;
|
||||||
@@ -13,20 +14,37 @@ describe('Step 2 – create race', () => {
|
|||||||
await harness.dispose();
|
await harness.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clicks Create a Race on Hosted Racing page', async () => {
|
it('opens the real Create Race confirmation modal with Last Settings / New Race options', async () => {
|
||||||
await harness.navigateToFixtureStep(1);
|
await harness.navigateToFixtureStep(2);
|
||||||
const page = harness.adapter.getPage();
|
const page = harness.adapter.getPage();
|
||||||
expect(page).not.toBeNull();
|
expect(page).not.toBeNull();
|
||||||
|
|
||||||
const bodyTextBefore = await page!.textContent('body');
|
const bodyTextBefore = await page!.textContent('body');
|
||||||
expect(bodyTextBefore).toContain('Create a Race');
|
expect(bodyTextBefore).toContain('Create a Race');
|
||||||
|
|
||||||
const result = await harness.executeStep(2, {});
|
const result = await harness.executeStep(2, {});
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
|
|
||||||
const bodyTextAfter = await page!.textContent('body');
|
await page!.waitForSelector(
|
||||||
expect(bodyTextAfter).toMatch(/Last Settings/i);
|
IRACING_SELECTORS.hostedRacing.createRaceModal,
|
||||||
|
);
|
||||||
|
|
||||||
|
const modalText = await page!.textContent(
|
||||||
|
IRACING_SELECTORS.hostedRacing.createRaceModal,
|
||||||
|
);
|
||||||
|
expect(modalText).toMatch(/Last Settings/i);
|
||||||
|
expect(modalText).toMatch(/New Race/i);
|
||||||
|
|
||||||
|
const lastSettingsButton = await page!.$(
|
||||||
|
IRACING_SELECTORS.hostedRacing.lastSettingsButton,
|
||||||
|
);
|
||||||
|
const newRaceButton = await page!.$(
|
||||||
|
IRACING_SELECTORS.hostedRacing.newRaceButton,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lastSettingsButton).not.toBeNull();
|
||||||
|
expect(newRaceButton).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import type { StepHarness } from '../support/StepHarness';
|
import type { StepHarness } from '../support/StepHarness';
|
||||||
import { createStepHarness } from '../support/StepHarness';
|
import { createStepHarness } from '../support/StepHarness';
|
||||||
|
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||||
|
|
||||||
describe('Step 3 – race information', () => {
|
describe('Step 3 – race information', () => {
|
||||||
let harness: StepHarness;
|
let harness: StepHarness;
|
||||||
@@ -13,26 +14,46 @@ describe('Step 3 – race information', () => {
|
|||||||
await harness.dispose();
|
await harness.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fills race information on Race Information page', async () => {
|
it('fills race information on Race Information page and persists values in form fields', async () => {
|
||||||
await harness.navigateToFixtureStep(3);
|
await harness.navigateToFixtureStep(3);
|
||||||
|
|
||||||
const page = harness.adapter.getPage();
|
const page = harness.adapter.getPage();
|
||||||
expect(page).not.toBeNull();
|
expect(page).not.toBeNull();
|
||||||
|
|
||||||
const sidebarRaceInfo = await page!.textContent(
|
const sidebarRaceInfo = await page!.textContent(
|
||||||
'#wizard-sidebar-link-set-session-information',
|
'#wizard-sidebar-link-set-session-information',
|
||||||
);
|
);
|
||||||
expect(sidebarRaceInfo).toContain('Race Information');
|
expect(sidebarRaceInfo).toContain('Race Information');
|
||||||
|
|
||||||
const result = await harness.executeStep(3, {
|
const config = {
|
||||||
sessionName: 'GridPilot E2E Session',
|
sessionName: 'GridPilot E2E Session',
|
||||||
password: 'secret',
|
password: 'secret',
|
||||||
description: 'Step 3 race information E2E',
|
description: 'Step 3 race information E2E',
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const result = await harness.executeStep(3, config);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
|
|
||||||
|
const sessionNameInput = page!
|
||||||
|
.locator(IRACING_SELECTORS.steps.sessionName)
|
||||||
|
.first();
|
||||||
|
const passwordInput = page!
|
||||||
|
.locator(IRACING_SELECTORS.steps.password)
|
||||||
|
.first();
|
||||||
|
const descriptionInput = page!
|
||||||
|
.locator(IRACING_SELECTORS.steps.description)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const sessionNameValue = await sessionNameInput.inputValue();
|
||||||
|
const passwordValue = await passwordInput.inputValue();
|
||||||
|
const descriptionValue = await descriptionInput.inputValue();
|
||||||
|
|
||||||
|
expect(sessionNameValue).toBe(config.sessionName);
|
||||||
|
expect(passwordValue).toBe(config.password);
|
||||||
|
expect(descriptionValue).toBe(config.description);
|
||||||
|
|
||||||
const footerText = await page!.textContent('.wizard-footer');
|
const footerText = await page!.textContent('.wizard-footer');
|
||||||
expect(footerText).toMatch(/Server Details|Admins/i);
|
expect(footerText).toMatch(/Server Details|Admins/i);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import type { StepHarness } from '../support/StepHarness';
|
import type { StepHarness } from '../support/StepHarness';
|
||||||
import { createStepHarness } from '../support/StepHarness';
|
import { createStepHarness } from '../support/StepHarness';
|
||||||
|
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||||
|
|
||||||
describe('Step 4 – server details', () => {
|
describe('Step 4 – server details', () => {
|
||||||
let harness: StepHarness;
|
let harness: StepHarness;
|
||||||
@@ -13,22 +14,41 @@ describe('Step 4 – server details', () => {
|
|||||||
await harness.dispose();
|
await harness.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('executes on Server Details page and progresses toward Admins', async () => {
|
it('executes on Server Details page, applies region/start toggle, and progresses toward Admins', async () => {
|
||||||
await harness.navigateToFixtureStep(4);
|
await harness.navigateToFixtureStep(4);
|
||||||
|
|
||||||
const page = harness.adapter.getPage();
|
const page = harness.adapter.getPage();
|
||||||
expect(page).not.toBeNull();
|
expect(page).not.toBeNull();
|
||||||
|
|
||||||
const sidebarServerDetails = await page!.textContent(
|
const sidebarServerDetails = await page!.textContent(
|
||||||
'#wizard-sidebar-link-set-server-details',
|
'#wizard-sidebar-link-set-server-details',
|
||||||
);
|
);
|
||||||
expect(sidebarServerDetails).toContain('Server Details');
|
expect(sidebarServerDetails).toContain('Server Details');
|
||||||
|
|
||||||
const result = await harness.executeStep(4, {});
|
const config = {
|
||||||
|
region: 'US-East-OH',
|
||||||
|
startNow: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await harness.executeStep(4, config);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
|
|
||||||
|
const currentServerHeader = await page!
|
||||||
|
.locator('#set-server-details button:has-text("Current Server")')
|
||||||
|
.first()
|
||||||
|
.innerText();
|
||||||
|
expect(currentServerHeader.toLowerCase()).toContain('us-east');
|
||||||
|
|
||||||
|
const startToggle = page!
|
||||||
|
.locator(IRACING_SELECTORS.steps.startNow)
|
||||||
|
.first();
|
||||||
|
const startNowChecked =
|
||||||
|
(await startToggle.getAttribute('checked')) !== null ||
|
||||||
|
(await startToggle.getAttribute('aria-checked')) === 'true';
|
||||||
|
expect(startNowChecked).toBe(true);
|
||||||
|
|
||||||
const footerText = await page!.textContent('.wizard-footer');
|
const footerText = await page!.textContent('.wizard-footer');
|
||||||
expect(footerText).toMatch(/Admins/i);
|
expect(footerText).toMatch(/Admins/i);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,25 +13,31 @@ describe('Step 5 – set admins', () => {
|
|||||||
await harness.dispose();
|
await harness.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('executes on Set Admins page and progresses to Time Limit', async () => {
|
it('executes on Set Admins page and leaves at least one admin in the selected admins table when progressing to Time Limit', async () => {
|
||||||
await harness.navigateToFixtureStep(5);
|
await harness.navigateToFixtureStep(5);
|
||||||
|
|
||||||
const page = harness.adapter.getPage();
|
const page = harness.adapter.getPage();
|
||||||
expect(page).not.toBeNull();
|
expect(page).not.toBeNull();
|
||||||
|
|
||||||
const sidebarAdmins = await page!.textContent(
|
const sidebarAdmins = await page!.textContent(
|
||||||
'#wizard-sidebar-link-set-admins',
|
'#wizard-sidebar-link-set-admins',
|
||||||
);
|
);
|
||||||
expect(sidebarAdmins).toContain('Admins');
|
expect(sidebarAdmins).toContain('Admins');
|
||||||
|
|
||||||
const bodyText = await page!.textContent('body');
|
const bodyText = await page!.textContent('body');
|
||||||
expect(bodyText).toContain('Add an Admin');
|
expect(bodyText).toContain('Add an Admin');
|
||||||
|
|
||||||
const result = await harness.executeStep(5, {});
|
const result = await harness.executeStep(5, {});
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
|
|
||||||
|
const selectedAdminsText =
|
||||||
|
(await page!.textContent(
|
||||||
|
'#set-admins tbody[data-testid="admin-display-name-list"]',
|
||||||
|
)) ?? '';
|
||||||
|
expect(selectedAdminsText.trim()).not.toEqual('');
|
||||||
|
|
||||||
const footerText = await page!.textContent('.wizard-footer');
|
const footerText = await page!.textContent('.wizard-footer');
|
||||||
expect(footerText).toContain('Time Limit');
|
expect(footerText).toContain('Time Limit');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ describe('Step 6 – admins', () => {
|
|||||||
await harness.dispose();
|
await harness.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('completes successfully from Set Admins page', async () => {
|
it('completes successfully from Set Admins page and leaves selected admins populated', async () => {
|
||||||
await harness.navigateToFixtureStep(5);
|
await harness.navigateToFixtureStep(5);
|
||||||
const page = harness.adapter.getPage();
|
const page = harness.adapter.getPage();
|
||||||
expect(page).not.toBeNull();
|
expect(page).not.toBeNull();
|
||||||
@@ -27,11 +27,17 @@ describe('Step 6 – admins', () => {
|
|||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const selectedAdminsText =
|
||||||
|
(await page!.textContent(
|
||||||
|
'#set-admins tbody[data-testid="admin-display-name-list"]',
|
||||||
|
)) ?? '';
|
||||||
|
expect(selectedAdminsText.trim()).not.toEqual('');
|
||||||
|
|
||||||
const footerText = await page!.textContent('.wizard-footer');
|
const footerText = await page!.textContent('.wizard-footer');
|
||||||
expect(footerText).toContain('Time Limit');
|
expect(footerText).toContain('Time Limit');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles Add Admin drawer state without regression', async () => {
|
it('handles Add Admin drawer state without regression and preserves selected admins list', async () => {
|
||||||
await harness.navigateToFixtureStep(6);
|
await harness.navigateToFixtureStep(6);
|
||||||
const page = harness.adapter.getPage();
|
const page = harness.adapter.getPage();
|
||||||
expect(page).not.toBeNull();
|
expect(page).not.toBeNull();
|
||||||
@@ -45,6 +51,12 @@ describe('Step 6 – admins', () => {
|
|||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const selectedAdminsText =
|
||||||
|
(await page!.textContent(
|
||||||
|
'#set-admins tbody[data-testid="admin-display-name-list"]',
|
||||||
|
)) ?? '';
|
||||||
|
expect(selectedAdminsText.trim()).not.toEqual('');
|
||||||
|
|
||||||
const footerText = await page!.textContent('.wizard-footer');
|
const footerText = await page!.textContent('.wizard-footer');
|
||||||
expect(footerText).toContain('Time Limit');
|
expect(footerText).toContain('Time Limit');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import type { StepHarness } from '../support/StepHarness';
|
import type { StepHarness } from '../support/StepHarness';
|
||||||
import { createStepHarness } from '../support/StepHarness';
|
import { createStepHarness } from '../support/StepHarness';
|
||||||
|
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||||
|
|
||||||
describe('Step 7 – time limits', () => {
|
describe('Step 7 – time limits', () => {
|
||||||
let harness: StepHarness;
|
let harness: StepHarness;
|
||||||
|
|
||||||
@@ -13,24 +14,36 @@ describe('Step 7 – time limits', () => {
|
|||||||
await harness.dispose();
|
await harness.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('executes on Time Limits page and navigates to Cars', async () => {
|
it('executes on Time Limits page, applies sliders, and navigates to Cars', async () => {
|
||||||
await harness.navigateToFixtureStep(7);
|
await harness.navigateToFixtureStep(7);
|
||||||
const page = harness.adapter.getPage();
|
const page = harness.adapter.getPage();
|
||||||
expect(page).not.toBeNull();
|
expect(page).not.toBeNull();
|
||||||
|
|
||||||
const stepIndicatorBefore = await page!.textContent('[data-indicator]');
|
const timeLimitContainer = page!
|
||||||
expect(stepIndicatorBefore).toContain('Time Limits');
|
.locator(IRACING_SELECTORS.wizard.stepContainers.timeLimit)
|
||||||
|
.first();
|
||||||
|
expect(await timeLimitContainer.count()).toBeGreaterThan(0);
|
||||||
|
|
||||||
const result = await harness.executeStep(7, {
|
const result = await harness.executeStep(7, {
|
||||||
practice: 10,
|
practice: 10,
|
||||||
qualify: 10,
|
qualify: 10,
|
||||||
race: 20,
|
race: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
|
|
||||||
const stepIndicatorAfter = await page!.textContent('[data-indicator]');
|
const raceSlider = page!
|
||||||
expect(stepIndicatorAfter).toContain('Set Cars');
|
.locator(IRACING_SELECTORS.steps.race)
|
||||||
|
.first();
|
||||||
|
const raceSliderExists = await raceSlider.count();
|
||||||
|
expect(raceSliderExists).toBeGreaterThan(0);
|
||||||
|
const raceValueAttr =
|
||||||
|
(await raceSlider.getAttribute('data-value')) ??
|
||||||
|
(await raceSlider.inputValue().catch(() => null));
|
||||||
|
expect(raceValueAttr).toBe('20');
|
||||||
|
|
||||||
|
const footerText = await page!.textContent('.wizard-footer');
|
||||||
|
expect(footerText).toMatch(/Cars/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import type { StepHarness } from '../support/StepHarness';
|
import type { StepHarness } from '../support/StepHarness';
|
||||||
import { createStepHarness } from '../support/StepHarness';
|
import { createStepHarness } from '../support/StepHarness';
|
||||||
|
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||||
|
|
||||||
describe('Step 8 – cars', () => {
|
describe('Step 8 – cars', () => {
|
||||||
let harness: StepHarness;
|
let harness: StepHarness;
|
||||||
|
|
||||||
@@ -14,17 +15,25 @@ describe('Step 8 – cars', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('alignment', () => {
|
describe('alignment', () => {
|
||||||
it('executes on Cars page in mock wizard', async () => {
|
it('executes on Cars page in mock wizard and exposes Add Car UI', async () => {
|
||||||
await harness.navigateToFixtureStep(8);
|
await harness.navigateToFixtureStep(8);
|
||||||
|
|
||||||
const page = harness.adapter.getPage();
|
const page = harness.adapter.getPage();
|
||||||
expect(page).not.toBeNull();
|
expect(page).not.toBeNull();
|
||||||
|
|
||||||
const stepIndicatorBefore = await page!.textContent('[data-indicator]');
|
const carsContainer = page!
|
||||||
expect(stepIndicatorBefore).toContain('Set Cars');
|
.locator(IRACING_SELECTORS.wizard.stepContainers.cars)
|
||||||
|
.first();
|
||||||
|
expect(await carsContainer.count()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const addCarButton = page!
|
||||||
|
.locator(IRACING_SELECTORS.steps.addCarButton)
|
||||||
|
.first();
|
||||||
|
const addCarText = await addCarButton.innerText();
|
||||||
|
expect(addCarText.toLowerCase()).toContain('add a car');
|
||||||
|
|
||||||
const result = await harness.executeStep(8, {});
|
const result = await harness.executeStep(8, {});
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,21 +14,28 @@ describe('Step 9 – add car', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('happy path', () => {
|
describe('happy path', () => {
|
||||||
it('executes on Add Car modal from Cars step', async () => {
|
it('adds a real car using the JSON-backed car list on Cars page', async () => {
|
||||||
await harness.navigateToFixtureStep(9);
|
await harness.navigateToFixtureStep(8);
|
||||||
|
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
const page = harness.adapter.getPage();
|
const page = harness.adapter.getPage();
|
||||||
expect(page).not.toBeNull();
|
expect(page).not.toBeNull();
|
||||||
|
|
||||||
const modalTitleBefore = await page!.textContent('[data-indicator="add-car"]');
|
|
||||||
expect(modalTitleBefore).toContain('Add a Car');
|
|
||||||
|
|
||||||
const result = await harness.executeStep(9, {
|
const result = await harness.executeStep(9, {
|
||||||
carSearch: 'Porsche 911 GT3 R',
|
carSearch: 'Acura ARX-06',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
|
|
||||||
|
const carsTable = page!
|
||||||
|
.locator('#select-car-set-cars table.table.table-striped')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
expect(await carsTable.count()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const acuraCell = carsTable.locator('tbody tr td >> text=Acura ARX-06 GTP');
|
||||||
|
expect(await acuraCell.count()).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,7 +59,7 @@ describe('Step 9 – add car', () => {
|
|||||||
await harness.executeStep(9, {
|
await harness.executeStep(9, {
|
||||||
carSearch: 'Porsche 911',
|
carSearch: 'Porsche 911',
|
||||||
});
|
});
|
||||||
}).rejects.toThrow(/Expected cars step/i);
|
}).rejects.toThrow(/Step 9 FAILED validation/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects when Track container is present instead of Cars page', async () => {
|
it('detects when Track container is present instead of Cars page', async () => {
|
||||||
@@ -63,7 +70,7 @@ describe('Step 9 – add car', () => {
|
|||||||
await harness.executeStep(9, {
|
await harness.executeStep(9, {
|
||||||
carSearch: 'Ferrari 488',
|
carSearch: 'Ferrari 488',
|
||||||
});
|
});
|
||||||
}).rejects.toThrow(/3 steps ahead|Track page/i);
|
}).rejects.toThrow(/Step 9 FAILED validation/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes validation when on Cars page', async () => {
|
it('passes validation when on Cars page', async () => {
|
||||||
@@ -71,10 +78,22 @@ describe('Step 9 – add car', () => {
|
|||||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
const result = await harness.executeStep(9, {
|
const result = await harness.executeStep(9, {
|
||||||
carSearch: 'Mazda MX-5',
|
carSearch: 'Acura ARX-06',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const page = harness.adapter.getPage();
|
||||||
|
expect(page).not.toBeNull();
|
||||||
|
|
||||||
|
const carsTable = page!
|
||||||
|
.locator('#select-car-set-cars table.table.table-striped')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
expect(await carsTable.count()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const acuraCell = carsTable.locator('tbody tr td >> text=Acura ARX-06 GTP');
|
||||||
|
expect(await acuraCell.count()).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('provides detailed error context in validation failure', async () => {
|
it('provides detailed error context in validation failure', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import type { StepHarness } from '../support/StepHarness';
|
import type { StepHarness } from '../support/StepHarness';
|
||||||
import { createStepHarness } from '../support/StepHarness';
|
import { createStepHarness } from '../support/StepHarness';
|
||||||
|
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||||
|
|
||||||
describe('Step 14 – time of day', () => {
|
describe('Step 14 – time of day', () => {
|
||||||
let harness: StepHarness;
|
let harness: StepHarness;
|
||||||
@@ -13,22 +14,40 @@ describe('Step 14 – time of day', () => {
|
|||||||
await harness.dispose();
|
await harness.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('executes on Time of Day page in mock wizard', async () => {
|
it('executes on Time of Day page and applies time-of-day slider from config', async () => {
|
||||||
await harness.navigateToFixtureStep(14);
|
await harness.navigateToFixtureStep(14);
|
||||||
|
|
||||||
const page = harness.adapter.getPage();
|
const page = harness.adapter.getPage();
|
||||||
expect(page).not.toBeNull();
|
expect(page).not.toBeNull();
|
||||||
|
|
||||||
|
const container = page!
|
||||||
|
.locator(IRACING_SELECTORS.wizard.stepContainers.timeOfDay)
|
||||||
|
.first();
|
||||||
|
expect(await container.count()).toBeGreaterThan(0);
|
||||||
|
|
||||||
const sidebarTimeOfDay = await page!.textContent(
|
const sidebarTimeOfDay = await page!.textContent(
|
||||||
'#wizard-sidebar-link-set-time-of-day',
|
'#wizard-sidebar-link-set-time-of-day',
|
||||||
);
|
);
|
||||||
expect(sidebarTimeOfDay).toContain('Time of Day');
|
expect(sidebarTimeOfDay).toContain('Time of Day');
|
||||||
|
|
||||||
const result = await harness.executeStep(14, {});
|
const config = { timeOfDay: 800 };
|
||||||
|
|
||||||
|
const result = await harness.executeStep(14, config);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
|
|
||||||
|
const timeSlider = page!
|
||||||
|
.locator(IRACING_SELECTORS.steps.timeOfDay)
|
||||||
|
.first();
|
||||||
|
const sliderExists = await timeSlider.count();
|
||||||
|
expect(sliderExists).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const valueAttr =
|
||||||
|
(await timeSlider.getAttribute('data-value')) ??
|
||||||
|
(await timeSlider.inputValue().catch(() => null));
|
||||||
|
expect(valueAttr).toBe(String(config.timeOfDay));
|
||||||
|
|
||||||
const footerText = await page!.textContent('.wizard-footer');
|
const footerText = await page!.textContent('.wizard-footer');
|
||||||
expect(footerText).toMatch(/Weather/i);
|
expect(footerText).toMatch(/Weather/i);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import type { StepHarness } from '../support/StepHarness';
|
import type { StepHarness } from '../support/StepHarness';
|
||||||
import { createStepHarness } from '../support/StepHarness';
|
import { createStepHarness } from '../support/StepHarness';
|
||||||
|
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||||
|
|
||||||
describe('Step 15 – weather', () => {
|
describe('Step 15 – weather', () => {
|
||||||
let harness: StepHarness;
|
let harness: StepHarness;
|
||||||
@@ -13,7 +14,7 @@ describe('Step 15 – weather', () => {
|
|||||||
await harness.dispose();
|
await harness.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('executes on Weather page in mock wizard', async () => {
|
it('executes on Weather page in mock wizard and applies weather config from JSON-backed controls', async () => {
|
||||||
await harness.navigateToFixtureStep(15);
|
await harness.navigateToFixtureStep(15);
|
||||||
|
|
||||||
const page = harness.adapter.getPage();
|
const page = harness.adapter.getPage();
|
||||||
@@ -27,9 +28,44 @@ describe('Step 15 – weather', () => {
|
|||||||
const bodyText = await page!.textContent('body');
|
const bodyText = await page!.textContent('body');
|
||||||
expect(bodyText).toMatch(/Weather Mode|Event weather/i);
|
expect(bodyText).toMatch(/Weather Mode|Event weather/i);
|
||||||
|
|
||||||
const result = await harness.executeStep(15, { timeOfDay: 800 });
|
const config = {
|
||||||
|
weatherType: '2',
|
||||||
|
temperature: 650,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await harness.executeStep(15, config);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
|
|
||||||
|
const weatherSelect = page!
|
||||||
|
.locator(IRACING_SELECTORS.steps.weatherType)
|
||||||
|
.first();
|
||||||
|
const weatherSelectCount = await weatherSelect.count();
|
||||||
|
|
||||||
|
if (weatherSelectCount > 0) {
|
||||||
|
const selectedWeatherValue =
|
||||||
|
(await weatherSelect.getAttribute('value')) ??
|
||||||
|
(await weatherSelect.textContent().catch(() => null));
|
||||||
|
expect(
|
||||||
|
(selectedWeatherValue ?? '').toLowerCase(),
|
||||||
|
).toMatch(/static|forecast|timeline|2/);
|
||||||
|
} else {
|
||||||
|
const radioGroup = page!.locator('[role="radiogroup"] input[type="radio"]').first();
|
||||||
|
const radioCount = await radioGroup.count();
|
||||||
|
expect(radioCount).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempSlider = page!
|
||||||
|
.locator(IRACING_SELECTORS.steps.temperature)
|
||||||
|
.first();
|
||||||
|
const tempExists = await tempSlider.count();
|
||||||
|
|
||||||
|
if (tempExists > 0) {
|
||||||
|
const tempValue =
|
||||||
|
(await tempSlider.getAttribute('data-value')) ??
|
||||||
|
(await tempSlider.inputValue().catch(() => null));
|
||||||
|
expect(tempValue).toBe(String(config.temperature));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -13,7 +13,7 @@ describe('Step 18 – track conditions (manual stop)', () => {
|
|||||||
await harness.dispose();
|
await harness.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not automate Track Conditions and surfaces unknown-step result', async () => {
|
it('treats Track Conditions as manual stop without invoking automation step 18', async () => {
|
||||||
await harness.navigateToFixtureStep(18);
|
await harness.navigateToFixtureStep(18);
|
||||||
|
|
||||||
const page = harness.adapter.getPage();
|
const page = harness.adapter.getPage();
|
||||||
@@ -24,9 +24,10 @@ describe('Step 18 – track conditions (manual stop)', () => {
|
|||||||
);
|
);
|
||||||
expect(sidebarTrackConditions).toContain('Track Conditions');
|
expect(sidebarTrackConditions).toContain('Track Conditions');
|
||||||
|
|
||||||
const result = await harness.executeStep(18, {});
|
const trackConditionsContainer = page!.locator('#set-track-conditions').first();
|
||||||
|
expect(await trackConditionsContainer.count()).toBeGreaterThan(0);
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
const bodyText = await page!.textContent('body');
|
||||||
expect(result.error).toContain('Unknown step: 18');
|
expect(bodyText).toMatch(/Track Conditions|Starting Track State/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -28,9 +28,9 @@ export interface PermissionCheckResult {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PermissionGuard for E2E tests.
|
* PermissionGuard for E2E tests.
|
||||||
*
|
*
|
||||||
* Checks macOS Accessibility and Screen Recording permissions
|
* Checks macOS Accessibility and Screen Recording permissions
|
||||||
* required for real nut.js automation. Provides graceful skip
|
* required for real native automation. Provides graceful skip
|
||||||
* logic for CI environments or when permissions are unavailable.
|
* logic for CI environments or when permissions are unavailable.
|
||||||
*/
|
*/
|
||||||
export class PermissionGuard {
|
export class PermissionGuard {
|
||||||
@@ -203,34 +203,17 @@ export class PermissionGuard {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check macOS Screen Recording permission without Electron.
|
* Check macOS Screen Recording permission without Electron.
|
||||||
* Uses CGPreflightScreenCaptureAccess (requires native code) or heuristics.
|
* Uses `screencapture` heuristics to detect denial.
|
||||||
*/
|
*/
|
||||||
private async checkMacOSScreenRecording(): Promise<boolean> {
|
private async checkMacOSScreenRecording(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Use screencapture command with minimal output
|
|
||||||
// -c captures to clipboard, -x prevents sound
|
|
||||||
// This will succeed even without permission but we can check for errors
|
|
||||||
const { stderr } = await execAsync('screencapture -x -c 2>&1 || true');
|
const { stderr } = await execAsync('screencapture -x -c 2>&1 || true');
|
||||||
|
|
||||||
// If there's a permission error in stderr, we don't have permission
|
|
||||||
if (stderr.includes('permission') || stderr.includes('denied')) {
|
if (stderr.includes('permission') || stderr.includes('denied')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional check: try to use nut.js screen capture
|
return true;
|
||||||
// This is the most reliable check but may throw
|
|
||||||
try {
|
|
||||||
const { screen } = await import('@nut-tree-fork/nut-js');
|
|
||||||
await screen.width();
|
|
||||||
return true;
|
|
||||||
} catch (nutError) {
|
|
||||||
const errorStr = String(nutError);
|
|
||||||
if (errorStr.includes('permission') || errorStr.includes('denied') || errorStr.includes('screen')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Other errors might be unrelated to permissions
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export async function createStepHarness(): Promise<StepHarness> {
|
|||||||
headless: true,
|
headless: true,
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
mode: 'mock',
|
mode: 'mock',
|
||||||
baseUrl: url,
|
|
||||||
},
|
},
|
||||||
logger,
|
logger,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => {
|
|||||||
return { repository, engine, useCase };
|
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 { repository, engine, useCase } = createFixtureEngine();
|
||||||
|
|
||||||
const config: any = {
|
const config: any = {
|
||||||
@@ -64,13 +64,13 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => {
|
|||||||
|
|
||||||
// Poll repository until automation loop completes
|
// Poll repository until automation loop completes
|
||||||
// MockAutomationEngineAdapter drives the step orchestrator internally.
|
// MockAutomationEngineAdapter drives the step orchestrator internally.
|
||||||
// Session should end in STOPPED_AT_STEP_18 when step 18 completes.
|
// Session should end in STOPPED_AT_STEP_18 after completing automated step 17.
|
||||||
// eslint-disable-next-line no-constant-condition
|
// eslint-disable-next-line no-constant-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
const sessions = await repository.findAll();
|
const sessions = await repository.findAll();
|
||||||
finalSession = sessions[0] ?? null;
|
finalSession = sessions[0] ?? null;
|
||||||
|
|
||||||
if (finalSession && (finalSession.state.isStoppedAtStep18() || finalSession.state.isFailed())) {
|
if (finalSession && finalSession.state.isStoppedAtStep18()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => {
|
|||||||
|
|
||||||
expect(finalSession).not.toBeNull();
|
expect(finalSession).not.toBeNull();
|
||||||
expect(finalSession!.state.isStoppedAtStep18()).toBe(true);
|
expect(finalSession!.state.isStoppedAtStep18()).toBe(true);
|
||||||
expect(finalSession!.currentStep.value).toBe(18);
|
expect(finalSession!.currentStep.value).toBe(17);
|
||||||
expect(finalSession!.startedAt).toBeInstanceOf(Date);
|
expect(finalSession!.startedAt).toBeInstanceOf(Date);
|
||||||
expect(finalSession!.completedAt).toBeInstanceOf(Date);
|
expect(finalSession!.completedAt).toBeInstanceOf(Date);
|
||||||
expect(finalSession!.errorMessage).toBeUndefined();
|
expect(finalSession!.errorMessage).toBeUndefined();
|
||||||
@@ -129,7 +129,7 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => {
|
|||||||
const sessions = await repository.findAll();
|
const sessions = await repository.findAll();
|
||||||
finalSession = sessions[0] ?? null;
|
finalSession = sessions[0] ?? null;
|
||||||
|
|
||||||
if (finalSession && (finalSession.state.isFailed() || finalSession.state.isStoppedAtStep18())) {
|
if (finalSession && finalSession.state.isFailed()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,11 +143,7 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => {
|
|||||||
await failingAdapter.disconnect();
|
await failingAdapter.disconnect();
|
||||||
|
|
||||||
expect(finalSession).not.toBeNull();
|
expect(finalSession).not.toBeNull();
|
||||||
expect(
|
expect(finalSession!.state.isFailed()).toBe(true);
|
||||||
finalSession!.state.isFailed() || finalSession!.state.isStoppedAtStep18(),
|
expect(finalSession!.errorMessage).toBeDefined();
|
||||||
).toBe(true);
|
|
||||||
if (finalSession!.state.isFailed()) {
|
|
||||||
expect(finalSession!.errorMessage).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import { PlaywrightAutomationAdapter, FixtureServer } from 'packages/infrastructure/adapters/automation';
|
import {
|
||||||
|
PlaywrightAutomationAdapter,
|
||||||
|
FixtureServer,
|
||||||
|
} from 'packages/infrastructure/adapters/automation';
|
||||||
import { StepId } from 'packages/domain/value-objects/StepId';
|
import { StepId } from 'packages/domain/value-objects/StepId';
|
||||||
|
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||||
|
|
||||||
describe('Workflow – steps 7–9 cars flow (fixture-backed)', () => {
|
describe('Workflow – steps 7–9 cars flow (fixture-backed)', () => {
|
||||||
let adapter: PlaywrightAutomationAdapter;
|
let adapter: PlaywrightAutomationAdapter;
|
||||||
@@ -18,7 +22,7 @@ describe('Workflow – steps 7–9 cars flow (fixture-backed)', () => {
|
|||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
mode: 'mock',
|
mode: 'mock',
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
await adapter.connect();
|
await adapter.connect();
|
||||||
});
|
});
|
||||||
@@ -28,7 +32,7 @@ describe('Workflow – steps 7–9 cars flow (fixture-backed)', () => {
|
|||||||
await server.stop();
|
await server.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('executes time limits, cars, and add car in sequence using fixtures', async () => {
|
it('executes time limits, cars, and add car in sequence using fixtures and leaves JSON-backed state', async () => {
|
||||||
await adapter.navigateToPage(server.getFixtureUrl(7));
|
await adapter.navigateToPage(server.getFixtureUrl(7));
|
||||||
const step7Result = await adapter.executeStep(StepId.create(7), {
|
const step7Result = await adapter.executeStep(StepId.create(7), {
|
||||||
practice: 10,
|
practice: 10,
|
||||||
@@ -37,14 +41,43 @@ describe('Workflow – steps 7–9 cars flow (fixture-backed)', () => {
|
|||||||
});
|
});
|
||||||
expect(step7Result.success).toBe(true);
|
expect(step7Result.success).toBe(true);
|
||||||
|
|
||||||
|
const page = adapter.getPage();
|
||||||
|
expect(page).not.toBeNull();
|
||||||
|
|
||||||
|
const raceSlider = page!
|
||||||
|
.locator(IRACING_SELECTORS.steps.race)
|
||||||
|
.first();
|
||||||
|
const raceSliderValue =
|
||||||
|
(await raceSlider.getAttribute('data-value')) ??
|
||||||
|
(await raceSlider.inputValue().catch(() => null));
|
||||||
|
expect(raceSliderValue).toBe('20');
|
||||||
|
|
||||||
await adapter.navigateToPage(server.getFixtureUrl(8));
|
await adapter.navigateToPage(server.getFixtureUrl(8));
|
||||||
const step8Result = await adapter.executeStep(StepId.create(8), {});
|
const step8Result = await adapter.executeStep(StepId.create(8), {});
|
||||||
expect(step8Result.success).toBe(true);
|
expect(step8Result.success).toBe(true);
|
||||||
|
|
||||||
|
const carsContainer = page!
|
||||||
|
.locator(IRACING_SELECTORS.wizard.stepContainers.cars)
|
||||||
|
.first();
|
||||||
|
expect(await carsContainer.count()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const addCarButton = page!
|
||||||
|
.locator(IRACING_SELECTORS.steps.addCarButton)
|
||||||
|
.first();
|
||||||
|
expect(await addCarButton.count()).toBeGreaterThan(0);
|
||||||
|
|
||||||
await adapter.navigateToPage(server.getFixtureUrl(9));
|
await adapter.navigateToPage(server.getFixtureUrl(9));
|
||||||
const step9Result = await adapter.executeStep(StepId.create(9), {
|
const step9Result = await adapter.executeStep(StepId.create(9), {
|
||||||
carSearch: 'Porsche 911 GT3 R',
|
carSearch: 'Acura ARX-06',
|
||||||
});
|
});
|
||||||
expect(step9Result.success).toBe(true);
|
expect(step9Result.success).toBe(true);
|
||||||
|
|
||||||
|
const carsTable = page!
|
||||||
|
.locator('#select-car-set-cars table.table.table-striped')
|
||||||
|
.first();
|
||||||
|
expect(await carsTable.count()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const acuraCell = carsTable.locator('tbody tr td >> text=Acura ARX-06 GTP');
|
||||||
|
expect(await acuraCell.count()).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -9,18 +9,19 @@ import * as path from 'path';
|
|||||||
* and runtime configuration via BrowserModeConfigLoader.
|
* and runtime configuration via BrowserModeConfigLoader.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Mock interfaces - will be replaced with actual imports in GREEN phase
|
type BrowserModeSource = 'env' | 'file' | 'default';
|
||||||
interface PlaywrightAutomationAdapter {
|
|
||||||
|
interface PlaywrightAutomationAdapterLike {
|
||||||
connect(): Promise<{ success: boolean; error?: string }>;
|
connect(): Promise<{ success: boolean; error?: string }>;
|
||||||
disconnect(): Promise<void>;
|
disconnect(): Promise<void>;
|
||||||
isConnected(): boolean;
|
isConnected(): boolean;
|
||||||
getBrowserMode(): 'headed' | 'headless';
|
getBrowserMode(): 'headed' | 'headless';
|
||||||
getBrowserModeSource(): 'GUI' | 'NODE_ENV';
|
getBrowserModeSource(): BrowserModeSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Browser Mode Integration - GREEN Phase', () => {
|
describe('Browser Mode Integration - GREEN Phase', () => {
|
||||||
const originalEnv = process.env;
|
const originalEnv = process.env;
|
||||||
let adapter: PlaywrightAutomationAdapter | null = null;
|
let adapter: PlaywrightAutomationAdapterLike | null = null;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
|
|||||||
@@ -9,15 +9,8 @@ import { CheckoutStateEnum } from '../../../packages/domain/value-objects/Checko
|
|||||||
* Tests verify HTML parsing for checkout price extraction and state detection.
|
* Tests verify HTML parsing for checkout price extraction and state detection.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Page {
|
type Page = ConstructorParameters<typeof CheckoutPriceExtractor>[0];
|
||||||
locator(selector: string): Locator;
|
type Locator = ReturnType<Page['locator']>;
|
||||||
}
|
|
||||||
|
|
||||||
interface Locator {
|
|
||||||
getAttribute(name: string): Promise<string | null>;
|
|
||||||
innerHTML(): Promise<string>;
|
|
||||||
textContent(): Promise<string | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('CheckoutPriceExtractor Integration', () => {
|
describe('CheckoutPriceExtractor Integration', () => {
|
||||||
let mockPage: Page;
|
let mockPage: Page;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
|
|||||||
const state = CheckoutState.ready();
|
const state = CheckoutState.ready();
|
||||||
|
|
||||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||||
Result.ok({ price, state })
|
Result.ok({ price, state, buttonHtml: '' })
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
||||||
@@ -60,7 +60,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
|
|||||||
const state = CheckoutState.ready();
|
const state = CheckoutState.ready();
|
||||||
|
|
||||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||||
Result.ok({ price, state })
|
Result.ok({ price, state, buttonHtml: '' })
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
||||||
@@ -80,7 +80,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
|
|||||||
const state = CheckoutState.ready();
|
const state = CheckoutState.ready();
|
||||||
|
|
||||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||||
Result.ok({ price, state })
|
Result.ok({ price, state, buttonHtml: '' })
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
||||||
@@ -100,7 +100,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
|
|||||||
const state = CheckoutState.ready();
|
const state = CheckoutState.ready();
|
||||||
|
|
||||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||||
Result.ok({ price, state })
|
Result.ok({ price, state, buttonHtml: '' })
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
||||||
@@ -118,7 +118,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
|
|||||||
const state = CheckoutState.insufficientFunds();
|
const state = CheckoutState.insufficientFunds();
|
||||||
|
|
||||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||||
Result.ok({ price, state })
|
Result.ok({ price, state, buttonHtml: '' })
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await useCase.execute();
|
const result = await useCase.execute();
|
||||||
@@ -139,7 +139,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||||
Result.ok({ price, state })
|
Result.ok({ price, state, buttonHtml: '' })
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||||
import { StartAutomationSessionUseCase } from '../../../../packages/application/use-cases/StartAutomationSessionUseCase';
|
import { StartAutomationSessionUseCase } from '../../../../packages/application/use-cases/StartAutomationSessionUseCase';
|
||||||
import { IAutomationEngine } from '../../../../packages/application/ports/IAutomationEngine';
|
import { IAutomationEngine } from '../../../../packages/application/ports/IAutomationEngine';
|
||||||
import { IBrowserAutomation } from '../../../../packages/application/ports/IBrowserAutomation';
|
import { IScreenAutomation } from '../../../../packages/application/ports/IScreenAutomation';
|
||||||
import { ISessionRepository } from '../../../../packages/application/ports/ISessionRepository';
|
import { ISessionRepository } from '../../../../packages/application/ports/ISessionRepository';
|
||||||
import { AutomationSession } from '../../../../packages/domain/entities/AutomationSession';
|
import { AutomationSession } from '../../../../packages/domain/entities/AutomationSession';
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ describe('StartAutomationSessionUseCase', () => {
|
|||||||
|
|
||||||
useCase = new StartAutomationSessionUseCase(
|
useCase = new StartAutomationSessionUseCase(
|
||||||
mockAutomationEngine as unknown as IAutomationEngine,
|
mockAutomationEngine as unknown as IAutomationEngine,
|
||||||
mockBrowserAutomation as unknown as IBrowserAutomation,
|
mockBrowserAutomation as unknown as IScreenAutomation,
|
||||||
mockSessionRepository as unknown as ISessionRepository
|
mockSessionRepository as unknown as ISessionRepository
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ describe('SessionCookieStore - Cookie Validation', () => {
|
|||||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||||
|
|
||||||
expect(result.isErr()).toBe(true);
|
expect(result.isErr()).toBe(true);
|
||||||
expect(result.unwrapErr()).toMatch(/domain mismatch/i);
|
expect(result.unwrapErr().message).toMatch(/domain mismatch/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should fail when cookie path is invalid for target', async () => {
|
test('should fail when cookie path is invalid for target', async () => {
|
||||||
@@ -81,7 +81,7 @@ describe('SessionCookieStore - Cookie Validation', () => {
|
|||||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||||
|
|
||||||
expect(result.isErr()).toBe(true);
|
expect(result.isErr()).toBe(true);
|
||||||
expect(result.unwrapErr()).toMatch(/path.*not valid/i);
|
expect(result.unwrapErr().message).toMatch(/path.*not valid/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should fail when required irsso_members cookie is missing', async () => {
|
test('should fail when required irsso_members cookie is missing', async () => {
|
||||||
@@ -102,7 +102,7 @@ describe('SessionCookieStore - Cookie Validation', () => {
|
|||||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||||
|
|
||||||
expect(result.isErr()).toBe(true);
|
expect(result.isErr()).toBe(true);
|
||||||
expect(result.unwrapErr()).toMatch(/required.*irsso_members/i);
|
expect(result.unwrapErr().message).toMatch(/required.*irsso_members/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should fail when required authtoken_members cookie is missing', async () => {
|
test('should fail when required authtoken_members cookie is missing', async () => {
|
||||||
@@ -123,14 +123,14 @@ describe('SessionCookieStore - Cookie Validation', () => {
|
|||||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||||
|
|
||||||
expect(result.isErr()).toBe(true);
|
expect(result.isErr()).toBe(true);
|
||||||
expect(result.unwrapErr()).toMatch(/required.*authtoken_members/i);
|
expect(result.unwrapErr().message).toMatch(/required.*authtoken_members/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should fail when no cookies are stored', () => {
|
test('should fail when no cookies are stored', () => {
|
||||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||||
|
|
||||||
expect(result.isErr()).toBe(true);
|
expect(result.isErr()).toBe(true);
|
||||||
expect(result.unwrapErr()).toMatch(/no cookies/i);
|
expect(result.unwrapErr().message).toMatch(/no cookies/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should validate cookies for members-ng.iracing.com domain', async () => {
|
test('should validate cookies for members-ng.iracing.com domain', async () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022", "DOM"],
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
"packages/*": ["packages/*"],
|
"packages/*": ["packages/*"],
|
||||||
"apps/*": ["apps/*"]
|
"apps/*": ["apps/*"]
|
||||||
},
|
},
|
||||||
"types": ["vitest/globals"]
|
"types": ["vitest/globals", "node"],
|
||||||
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"packages/**/*",
|
"packages/**/*",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"dist",
|
"dist",
|
||||||
"**/*.js"
|
"**/*.js",
|
||||||
|
"tests/e2e/step-definitions/automation.steps.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E Test Configuration
|
* E2E Test Configuration
|
||||||
*
|
*
|
||||||
* IMPORTANT: E2E tests run against real OS automation.
|
* IMPORTANT: E2E tests run against real OS automation.
|
||||||
* This configuration includes strict timeouts to prevent hanging.
|
* This configuration includes strict timeouts to prevent hanging.
|
||||||
*/
|
*/
|
||||||
|
const RUN_REAL_AUTOMATION_SMOKE = process.env.RUN_REAL_AUTOMATION_SMOKE === '1';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['tests/e2e/**/*.e2e.test.ts'],
|
include: ['tests/e2e/**/*.e2e.test.ts'],
|
||||||
|
exclude: RUN_REAL_AUTOMATION_SMOKE ? [] : ['tests/e2e/automation.e2e.test.ts'],
|
||||||
// E2E tests use real automation - set strict timeouts to prevent hanging
|
// E2E tests use real automation - set strict timeouts to prevent hanging
|
||||||
// Individual tests: 30 seconds max
|
// Individual tests: 30 seconds max
|
||||||
testTimeout: 30000,
|
testTimeout: 30000,
|
||||||
|
|||||||
Reference in New Issue
Block a user