wip
This commit is contained in:
@@ -1,396 +1,14 @@
|
||||
// Set DISPLAY_SCALE_FACTOR=1 BEFORE any imports that use it
|
||||
// Templates are already at 2x Retina resolution from macOS screenshot tool
|
||||
// So we don't want to scale them again
|
||||
process.env.DISPLAY_SCALE_FACTOR = '1';
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { AutomationSession } from '../../packages/domain/entities/AutomationSession';
|
||||
import { StartAutomationSessionUseCase } from '../../packages/application/use-cases/StartAutomationSessionUseCase';
|
||||
import { InMemorySessionRepository } from '../../packages/infrastructure/repositories/InMemorySessionRepository';
|
||||
import { NutJsAutomationAdapter } from '../../packages/infrastructure/adapters/automation/NutJsAutomationAdapter';
|
||||
import { AutomationEngineAdapter } from '../../packages/infrastructure/adapters/automation/AutomationEngineAdapter';
|
||||
import { NoOpLogAdapter } from '../../packages/infrastructure/adapters/logging/NoOpLogAdapter';
|
||||
import type { IScreenAutomation } from '../../packages/application/ports/IScreenAutomation';
|
||||
import type { ISessionRepository } from '../../packages/application/ports/ISessionRepository';
|
||||
import { StepId } from '../../packages/domain/value-objects/StepId';
|
||||
import { permissionGuard, shouldSkipRealAutomationTests } from './support/PermissionGuard';
|
||||
import { describe } from 'vitest';
|
||||
|
||||
/**
|
||||
* E2E Tests for REAL Automation against REAL iRacing Website
|
||||
* Legacy real automation smoke suite.
|
||||
*
|
||||
* ZERO MOCKS - REAL AUTOMATION against the REAL iRacing website.
|
||||
* Native OS-level automation has been removed.
|
||||
* Real iRacing automation is not currently supported.
|
||||
*
|
||||
* These tests:
|
||||
* 1. Launch a REAL Chrome browser to https://members-ng.iracing.com/web/racing/hosted/browse-sessions
|
||||
* 2. Use REAL NutJsAutomationAdapter with REAL nut.js mouse/keyboard
|
||||
* 3. Use REAL TemplateMatchingService with REAL OpenCV template matching
|
||||
* 4. Capture REAL screenshots from the ACTUAL display
|
||||
*
|
||||
* Tests will FAIL if:
|
||||
* - Permissions not granted (macOS accessibility/screen recording)
|
||||
* - User is NOT logged into iRacing
|
||||
* - Template images don't match the real iRacing UI
|
||||
* - Real automation cannot execute
|
||||
*
|
||||
* PREREQUISITES:
|
||||
* - User must be logged into iRacing in their default browser
|
||||
* - macOS accessibility and screen recording permissions must be granted
|
||||
* This file is retained only as historical documentation and is
|
||||
* explicitly skipped so it does not participate in normal E2E runs.
|
||||
*/
|
||||
|
||||
const IRACING_URL = 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions';
|
||||
const WINDOW_TITLE_PATTERN = 'iRacing';
|
||||
|
||||
const RUN_REAL_AUTOMATION_SMOKE = process.env.RUN_REAL_AUTOMATION_SMOKE === '1';
|
||||
const describeSmoke = RUN_REAL_AUTOMATION_SMOKE ? describe : describe.skip;
|
||||
|
||||
let skipReason: string | null = null;
|
||||
let browserProcess: ChildProcess | null = null;
|
||||
|
||||
// Configurable wait time for page load (iRacing has heavy JavaScript)
|
||||
const PAGE_LOAD_WAIT_MS = 10000; // 10 seconds for page to fully load
|
||||
|
||||
/**
|
||||
* Launch the DEFAULT browser to iRacing website using macOS `open` command.
|
||||
* This opens the URL in the user's default browser where they are already logged in.
|
||||
*/
|
||||
async function launchBrowserToIRacing(): Promise<{ success: boolean; pid?: number; error?: string }> {
|
||||
// Use macOS `open` command to open URL in the DEFAULT browser
|
||||
// No -a flag = uses the system default browser
|
||||
browserProcess = spawn('open', [IRACING_URL], {
|
||||
detached: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Wait for browser to start and page to fully load
|
||||
// iRacing's pages have heavy JavaScript that takes time to render
|
||||
console.log(`⏳ Waiting ${PAGE_LOAD_WAIT_MS / 1000} seconds for iRacing page to fully load...`);
|
||||
await new Promise(resolve => setTimeout(resolve, PAGE_LOAD_WAIT_MS));
|
||||
console.log('✓ Page load wait complete');
|
||||
|
||||
// The `open` command returns immediately
|
||||
if (browserProcess.pid) {
|
||||
return { success: true, pid: browserProcess.pid };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Failed to open default browser' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the browser window.
|
||||
* Note: Since we use `open` command, we can't directly kill the browser.
|
||||
* The browser will remain open after tests complete.
|
||||
*/
|
||||
async function closeBrowser(): Promise<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);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe.skip('Real automation smoke – REAL iRacing Website (native automation removed)', () => {
|
||||
// No-op: native OS-level real automation has been removed.
|
||||
});
|
||||
|
||||
@@ -1,737 +1,8 @@
|
||||
import { Given, When, Then, Before, After, BeforeAll, AfterAll } from '@cucumber/cucumber';
|
||||
import { expect } from 'vitest';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { AutomationSession } from '../../../packages/domain/entities/AutomationSession';
|
||||
import { StartAutomationSessionUseCase } from '../../../packages/application/use-cases/StartAutomationSessionUseCase';
|
||||
import { InMemorySessionRepository } from '../../../packages/infrastructure/repositories/InMemorySessionRepository';
|
||||
import { NutJsAutomationAdapter } from '../../../packages/infrastructure/adapters/automation/NutJsAutomationAdapter';
|
||||
import { AutomationEngineAdapter } from '../../../packages/infrastructure/adapters/automation/AutomationEngineAdapter';
|
||||
import { NoOpLogAdapter } from '../../../packages/infrastructure/adapters/logging/NoOpLogAdapter';
|
||||
import type { IScreenAutomation } from '../../../packages/application/ports/IScreenAutomation';
|
||||
import type { ISessionRepository } from '../../../packages/application/ports/ISessionRepository';
|
||||
import { StepId } from '../../../packages/domain/value-objects/StepId';
|
||||
import { permissionGuard, shouldSkipRealAutomationTests } from '../support/PermissionGuard';
|
||||
|
||||
/**
|
||||
* E2E Test Context - REAL iRacing Website Automation
|
||||
* Legacy Cucumber step definitions for real iRacing automation.
|
||||
*
|
||||
* ZERO MOCKS. ZERO FIXTURES. ZERO FAKE HTML PAGES.
|
||||
*
|
||||
* Uses 100% REAL adapters against the REAL iRacing website:
|
||||
* - NutJsAutomationAdapter: REAL nut.js mouse/keyboard automation
|
||||
* - Real screen capture and template matching via OpenCV
|
||||
* - InMemorySessionRepository: Real in-memory persistence (not a mock)
|
||||
* - REAL Chrome browser pointing to https://members.iracing.com
|
||||
*
|
||||
* These tests run against the REAL iRacing website and require:
|
||||
* - macOS with display access
|
||||
* - Accessibility permissions for nut.js
|
||||
* - Screen Recording permissions
|
||||
* - User must be LOGGED INTO iRacing (tests cannot automate login)
|
||||
*
|
||||
* If these requirements are not met, tests will be SKIPPED gracefully.
|
||||
* Native OS-level automation and these steps have been retired.
|
||||
* This file is excluded from TypeScript builds and is kept only as
|
||||
* historical documentation. No executable step definitions remain.
|
||||
*/
|
||||
interface TestContext {
|
||||
sessionRepository: ISessionRepository;
|
||||
screenAutomation: IScreenAutomation;
|
||||
startAutomationUseCase: StartAutomationSessionUseCase;
|
||||
currentSession: AutomationSession | null;
|
||||
sessionConfig: Record<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);
|
||||
});
|
||||
export {};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 2 – create race', () => {
|
||||
let harness: StepHarness;
|
||||
@@ -13,20 +14,37 @@ describe('Step 2 – create race', () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('clicks Create a Race on Hosted Racing page', async () => {
|
||||
await harness.navigateToFixtureStep(1);
|
||||
it('opens the real Create Race confirmation modal with Last Settings / New Race options', async () => {
|
||||
await harness.navigateToFixtureStep(2);
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
|
||||
const bodyTextBefore = await page!.textContent('body');
|
||||
expect(bodyTextBefore).toContain('Create a Race');
|
||||
|
||||
|
||||
const result = await harness.executeStep(2, {});
|
||||
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const bodyTextAfter = await page!.textContent('body');
|
||||
expect(bodyTextAfter).toMatch(/Last Settings/i);
|
||||
|
||||
await page!.waitForSelector(
|
||||
IRACING_SELECTORS.hostedRacing.createRaceModal,
|
||||
);
|
||||
|
||||
const modalText = await page!.textContent(
|
||||
IRACING_SELECTORS.hostedRacing.createRaceModal,
|
||||
);
|
||||
expect(modalText).toMatch(/Last Settings/i);
|
||||
expect(modalText).toMatch(/New Race/i);
|
||||
|
||||
const lastSettingsButton = await page!.$(
|
||||
IRACING_SELECTORS.hostedRacing.lastSettingsButton,
|
||||
);
|
||||
const newRaceButton = await page!.$(
|
||||
IRACING_SELECTORS.hostedRacing.newRaceButton,
|
||||
);
|
||||
|
||||
expect(lastSettingsButton).not.toBeNull();
|
||||
expect(newRaceButton).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 3 – race information', () => {
|
||||
let harness: StepHarness;
|
||||
@@ -13,26 +14,46 @@ describe('Step 3 – race information', () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('fills race information on Race Information page', async () => {
|
||||
it('fills race information on Race Information page and persists values in form fields', async () => {
|
||||
await harness.navigateToFixtureStep(3);
|
||||
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
|
||||
const sidebarRaceInfo = await page!.textContent(
|
||||
'#wizard-sidebar-link-set-session-information',
|
||||
);
|
||||
expect(sidebarRaceInfo).toContain('Race Information');
|
||||
|
||||
const result = await harness.executeStep(3, {
|
||||
|
||||
const config = {
|
||||
sessionName: 'GridPilot E2E Session',
|
||||
password: 'secret',
|
||||
description: 'Step 3 race information E2E',
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
const result = await harness.executeStep(3, config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
|
||||
const sessionNameInput = page!
|
||||
.locator(IRACING_SELECTORS.steps.sessionName)
|
||||
.first();
|
||||
const passwordInput = page!
|
||||
.locator(IRACING_SELECTORS.steps.password)
|
||||
.first();
|
||||
const descriptionInput = page!
|
||||
.locator(IRACING_SELECTORS.steps.description)
|
||||
.first();
|
||||
|
||||
const sessionNameValue = await sessionNameInput.inputValue();
|
||||
const passwordValue = await passwordInput.inputValue();
|
||||
const descriptionValue = await descriptionInput.inputValue();
|
||||
|
||||
expect(sessionNameValue).toBe(config.sessionName);
|
||||
expect(passwordValue).toBe(config.password);
|
||||
expect(descriptionValue).toBe(config.description);
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toMatch(/Server Details|Admins/i);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 4 – server details', () => {
|
||||
let harness: StepHarness;
|
||||
@@ -13,22 +14,41 @@ describe('Step 4 – server details', () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('executes on Server Details page and progresses toward Admins', async () => {
|
||||
it('executes on Server Details page, applies region/start toggle, and progresses toward Admins', async () => {
|
||||
await harness.navigateToFixtureStep(4);
|
||||
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
|
||||
const sidebarServerDetails = await page!.textContent(
|
||||
'#wizard-sidebar-link-set-server-details',
|
||||
);
|
||||
expect(sidebarServerDetails).toContain('Server Details');
|
||||
|
||||
const result = await harness.executeStep(4, {});
|
||||
|
||||
|
||||
const config = {
|
||||
region: 'US-East-OH',
|
||||
startNow: true,
|
||||
};
|
||||
|
||||
const result = await harness.executeStep(4, config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
|
||||
const currentServerHeader = await page!
|
||||
.locator('#set-server-details button:has-text("Current Server")')
|
||||
.first()
|
||||
.innerText();
|
||||
expect(currentServerHeader.toLowerCase()).toContain('us-east');
|
||||
|
||||
const startToggle = page!
|
||||
.locator(IRACING_SELECTORS.steps.startNow)
|
||||
.first();
|
||||
const startNowChecked =
|
||||
(await startToggle.getAttribute('checked')) !== null ||
|
||||
(await startToggle.getAttribute('aria-checked')) === 'true';
|
||||
expect(startNowChecked).toBe(true);
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toMatch(/Admins/i);
|
||||
});
|
||||
|
||||
@@ -13,25 +13,31 @@ describe('Step 5 – set admins', () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('executes on Set Admins page and progresses to Time Limit', async () => {
|
||||
it('executes on Set Admins page and leaves at least one admin in the selected admins table when progressing to Time Limit', async () => {
|
||||
await harness.navigateToFixtureStep(5);
|
||||
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
|
||||
const sidebarAdmins = await page!.textContent(
|
||||
'#wizard-sidebar-link-set-admins',
|
||||
);
|
||||
expect(sidebarAdmins).toContain('Admins');
|
||||
|
||||
|
||||
const bodyText = await page!.textContent('body');
|
||||
expect(bodyText).toContain('Add an Admin');
|
||||
|
||||
|
||||
const result = await harness.executeStep(5, {});
|
||||
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
|
||||
const selectedAdminsText =
|
||||
(await page!.textContent(
|
||||
'#set-admins tbody[data-testid="admin-display-name-list"]',
|
||||
)) ?? '';
|
||||
expect(selectedAdminsText.trim()).not.toEqual('');
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toContain('Time Limit');
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('Step 6 – admins', () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('completes successfully from Set Admins page', async () => {
|
||||
it('completes successfully from Set Admins page and leaves selected admins populated', async () => {
|
||||
await harness.navigateToFixtureStep(5);
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
@@ -27,11 +27,17 @@ describe('Step 6 – admins', () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const selectedAdminsText =
|
||||
(await page!.textContent(
|
||||
'#set-admins tbody[data-testid="admin-display-name-list"]',
|
||||
)) ?? '';
|
||||
expect(selectedAdminsText.trim()).not.toEqual('');
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toContain('Time Limit');
|
||||
});
|
||||
|
||||
it('handles Add Admin drawer state without regression', async () => {
|
||||
it('handles Add Admin drawer state without regression and preserves selected admins list', async () => {
|
||||
await harness.navigateToFixtureStep(6);
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
@@ -45,6 +51,12 @@ describe('Step 6 – admins', () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const selectedAdminsText =
|
||||
(await page!.textContent(
|
||||
'#set-admins tbody[data-testid="admin-display-name-list"]',
|
||||
)) ?? '';
|
||||
expect(selectedAdminsText.trim()).not.toEqual('');
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toContain('Time Limit');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
|
||||
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 7 – time limits', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
@@ -13,24 +14,36 @@ describe('Step 7 – time limits', () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('executes on Time Limits page and navigates to Cars', async () => {
|
||||
it('executes on Time Limits page, applies sliders, and navigates to Cars', async () => {
|
||||
await harness.navigateToFixtureStep(7);
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const stepIndicatorBefore = await page!.textContent('[data-indicator]');
|
||||
expect(stepIndicatorBefore).toContain('Time Limits');
|
||||
|
||||
|
||||
const timeLimitContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.timeLimit)
|
||||
.first();
|
||||
expect(await timeLimitContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const result = await harness.executeStep(7, {
|
||||
practice: 10,
|
||||
qualify: 10,
|
||||
race: 20,
|
||||
});
|
||||
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const stepIndicatorAfter = await page!.textContent('[data-indicator]');
|
||||
expect(stepIndicatorAfter).toContain('Set Cars');
|
||||
|
||||
const raceSlider = page!
|
||||
.locator(IRACING_SELECTORS.steps.race)
|
||||
.first();
|
||||
const raceSliderExists = await raceSlider.count();
|
||||
expect(raceSliderExists).toBeGreaterThan(0);
|
||||
const raceValueAttr =
|
||||
(await raceSlider.getAttribute('data-value')) ??
|
||||
(await raceSlider.inputValue().catch(() => null));
|
||||
expect(raceValueAttr).toBe('20');
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toMatch(/Cars/i);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
|
||||
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 8 – cars', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
@@ -14,17 +15,25 @@ describe('Step 8 – cars', () => {
|
||||
});
|
||||
|
||||
describe('alignment', () => {
|
||||
it('executes on Cars page in mock wizard', async () => {
|
||||
it('executes on Cars page in mock wizard and exposes Add Car UI', async () => {
|
||||
await harness.navigateToFixtureStep(8);
|
||||
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const stepIndicatorBefore = await page!.textContent('[data-indicator]');
|
||||
expect(stepIndicatorBefore).toContain('Set Cars');
|
||||
|
||||
|
||||
const carsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.cars)
|
||||
.first();
|
||||
expect(await carsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const addCarButton = page!
|
||||
.locator(IRACING_SELECTORS.steps.addCarButton)
|
||||
.first();
|
||||
const addCarText = await addCarButton.innerText();
|
||||
expect(addCarText.toLowerCase()).toContain('add a car');
|
||||
|
||||
const result = await harness.executeStep(8, {});
|
||||
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -14,21 +14,28 @@ describe('Step 9 – add car', () => {
|
||||
});
|
||||
|
||||
describe('happy path', () => {
|
||||
it('executes on Add Car modal from Cars step', async () => {
|
||||
await harness.navigateToFixtureStep(9);
|
||||
it('adds a real car using the JSON-backed car list on Cars page', async () => {
|
||||
await harness.navigateToFixtureStep(8);
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const modalTitleBefore = await page!.textContent('[data-indicator="add-car"]');
|
||||
expect(modalTitleBefore).toContain('Add a Car');
|
||||
|
||||
const result = await harness.executeStep(9, {
|
||||
carSearch: 'Porsche 911 GT3 R',
|
||||
carSearch: 'Acura ARX-06',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const carsTable = page!
|
||||
.locator('#select-car-set-cars table.table.table-striped')
|
||||
.first();
|
||||
|
||||
expect(await carsTable.count()).toBeGreaterThan(0);
|
||||
|
||||
const acuraCell = carsTable.locator('tbody tr td >> text=Acura ARX-06 GTP');
|
||||
expect(await acuraCell.count()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +59,7 @@ describe('Step 9 – add car', () => {
|
||||
await harness.executeStep(9, {
|
||||
carSearch: 'Porsche 911',
|
||||
});
|
||||
}).rejects.toThrow(/Expected cars step/i);
|
||||
}).rejects.toThrow(/Step 9 FAILED validation/i);
|
||||
});
|
||||
|
||||
it('detects when Track container is present instead of Cars page', async () => {
|
||||
@@ -63,7 +70,7 @@ describe('Step 9 – add car', () => {
|
||||
await harness.executeStep(9, {
|
||||
carSearch: 'Ferrari 488',
|
||||
});
|
||||
}).rejects.toThrow(/3 steps ahead|Track page/i);
|
||||
}).rejects.toThrow(/Step 9 FAILED validation/i);
|
||||
});
|
||||
|
||||
it('passes validation when on Cars page', async () => {
|
||||
@@ -71,10 +78,22 @@ describe('Step 9 – add car', () => {
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
const result = await harness.executeStep(9, {
|
||||
carSearch: 'Mazda MX-5',
|
||||
carSearch: 'Acura ARX-06',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const carsTable = page!
|
||||
.locator('#select-car-set-cars table.table.table-striped')
|
||||
.first();
|
||||
|
||||
expect(await carsTable.count()).toBeGreaterThan(0);
|
||||
|
||||
const acuraCell = carsTable.locator('tbody tr td >> text=Acura ARX-06 GTP');
|
||||
expect(await acuraCell.count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('provides detailed error context in validation failure', async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 14 – time of day', () => {
|
||||
let harness: StepHarness;
|
||||
@@ -13,22 +14,40 @@ describe('Step 14 – time of day', () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('executes on Time of Day page in mock wizard', async () => {
|
||||
it('executes on Time of Day page and applies time-of-day slider from config', async () => {
|
||||
await harness.navigateToFixtureStep(14);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const container = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.timeOfDay)
|
||||
.first();
|
||||
expect(await container.count()).toBeGreaterThan(0);
|
||||
|
||||
const sidebarTimeOfDay = await page!.textContent(
|
||||
'#wizard-sidebar-link-set-time-of-day',
|
||||
);
|
||||
expect(sidebarTimeOfDay).toContain('Time of Day');
|
||||
|
||||
const result = await harness.executeStep(14, {});
|
||||
const config = { timeOfDay: 800 };
|
||||
|
||||
const result = await harness.executeStep(14, config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const timeSlider = page!
|
||||
.locator(IRACING_SELECTORS.steps.timeOfDay)
|
||||
.first();
|
||||
const sliderExists = await timeSlider.count();
|
||||
expect(sliderExists).toBeGreaterThan(0);
|
||||
|
||||
const valueAttr =
|
||||
(await timeSlider.getAttribute('data-value')) ??
|
||||
(await timeSlider.inputValue().catch(() => null));
|
||||
expect(valueAttr).toBe(String(config.timeOfDay));
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toMatch(/Weather/i);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 15 – weather', () => {
|
||||
let harness: StepHarness;
|
||||
@@ -13,7 +14,7 @@ describe('Step 15 – weather', () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('executes on Weather page in mock wizard', async () => {
|
||||
it('executes on Weather page in mock wizard and applies weather config from JSON-backed controls', async () => {
|
||||
await harness.navigateToFixtureStep(15);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
@@ -27,9 +28,44 @@ describe('Step 15 – weather', () => {
|
||||
const bodyText = await page!.textContent('body');
|
||||
expect(bodyText).toMatch(/Weather Mode|Event weather/i);
|
||||
|
||||
const result = await harness.executeStep(15, { timeOfDay: 800 });
|
||||
const config = {
|
||||
weatherType: '2',
|
||||
temperature: 650,
|
||||
};
|
||||
|
||||
const result = await harness.executeStep(15, config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const weatherSelect = page!
|
||||
.locator(IRACING_SELECTORS.steps.weatherType)
|
||||
.first();
|
||||
const weatherSelectCount = await weatherSelect.count();
|
||||
|
||||
if (weatherSelectCount > 0) {
|
||||
const selectedWeatherValue =
|
||||
(await weatherSelect.getAttribute('value')) ??
|
||||
(await weatherSelect.textContent().catch(() => null));
|
||||
expect(
|
||||
(selectedWeatherValue ?? '').toLowerCase(),
|
||||
).toMatch(/static|forecast|timeline|2/);
|
||||
} else {
|
||||
const radioGroup = page!.locator('[role="radiogroup"] input[type="radio"]').first();
|
||||
const radioCount = await radioGroup.count();
|
||||
expect(radioCount).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const tempSlider = page!
|
||||
.locator(IRACING_SELECTORS.steps.temperature)
|
||||
.first();
|
||||
const tempExists = await tempSlider.count();
|
||||
|
||||
if (tempExists > 0) {
|
||||
const tempValue =
|
||||
(await tempSlider.getAttribute('data-value')) ??
|
||||
(await tempSlider.inputValue().catch(() => null));
|
||||
expect(tempValue).toBe(String(config.temperature));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ describe('Step 18 – track conditions (manual stop)', () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('does not automate Track Conditions and surfaces unknown-step result', async () => {
|
||||
it('treats Track Conditions as manual stop without invoking automation step 18', async () => {
|
||||
await harness.navigateToFixtureStep(18);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
@@ -24,9 +24,10 @@ describe('Step 18 – track conditions (manual stop)', () => {
|
||||
);
|
||||
expect(sidebarTrackConditions).toContain('Track Conditions');
|
||||
|
||||
const result = await harness.executeStep(18, {});
|
||||
const trackConditionsContainer = page!.locator('#set-track-conditions').first();
|
||||
expect(await trackConditionsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Unknown step: 18');
|
||||
const bodyText = await page!.textContent('body');
|
||||
expect(bodyText).toMatch(/Track Conditions|Starting Track State/i);
|
||||
});
|
||||
});
|
||||
@@ -28,9 +28,9 @@ export interface PermissionCheckResult {
|
||||
|
||||
/**
|
||||
* PermissionGuard for E2E tests.
|
||||
*
|
||||
*
|
||||
* Checks macOS Accessibility and Screen Recording permissions
|
||||
* required for real nut.js automation. Provides graceful skip
|
||||
* required for real native automation. Provides graceful skip
|
||||
* logic for CI environments or when permissions are unavailable.
|
||||
*/
|
||||
export class PermissionGuard {
|
||||
@@ -203,34 +203,17 @@ export class PermissionGuard {
|
||||
|
||||
/**
|
||||
* Check macOS Screen Recording permission without Electron.
|
||||
* Uses CGPreflightScreenCaptureAccess (requires native code) or heuristics.
|
||||
* Uses `screencapture` heuristics to detect denial.
|
||||
*/
|
||||
private async checkMacOSScreenRecording(): Promise<boolean> {
|
||||
try {
|
||||
// Use screencapture command with minimal output
|
||||
// -c captures to clipboard, -x prevents sound
|
||||
// This will succeed even without permission but we can check for errors
|
||||
const { stderr } = await execAsync('screencapture -x -c 2>&1 || true');
|
||||
|
||||
// If there's a permission error in stderr, we don't have permission
|
||||
|
||||
if (stderr.includes('permission') || stderr.includes('denied')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional check: try to use nut.js screen capture
|
||||
// This is the most reliable check but may throw
|
||||
try {
|
||||
const { screen } = await import('@nut-tree-fork/nut-js');
|
||||
await screen.width();
|
||||
return true;
|
||||
} catch (nutError) {
|
||||
const errorStr = String(nutError);
|
||||
if (errorStr.includes('permission') || errorStr.includes('denied') || errorStr.includes('screen')) {
|
||||
return false;
|
||||
}
|
||||
// Other errors might be unrelated to permissions
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ export async function createStepHarness(): Promise<StepHarness> {
|
||||
headless: true,
|
||||
timeout: 5000,
|
||||
mode: 'mock',
|
||||
baseUrl: url,
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => {
|
||||
return { repository, engine, useCase };
|
||||
}
|
||||
|
||||
it('runs 1–18 from use case to STOPPED_AT_STEP_18', async () => {
|
||||
it('runs 1–17 from use case and stops automation at manual Track Conditions (STOPPED_AT_STEP_18)', async () => {
|
||||
const { repository, engine, useCase } = createFixtureEngine();
|
||||
|
||||
const config: any = {
|
||||
@@ -64,13 +64,13 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => {
|
||||
|
||||
// Poll repository until automation loop completes
|
||||
// MockAutomationEngineAdapter drives the step orchestrator internally.
|
||||
// Session should end in STOPPED_AT_STEP_18 when step 18 completes.
|
||||
// Session should end in STOPPED_AT_STEP_18 after completing automated step 17.
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const sessions = await repository.findAll();
|
||||
finalSession = sessions[0] ?? null;
|
||||
|
||||
if (finalSession && (finalSession.state.isStoppedAtStep18() || finalSession.state.isFailed())) {
|
||||
if (finalSession && finalSession.state.isStoppedAtStep18()) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => {
|
||||
|
||||
expect(finalSession).not.toBeNull();
|
||||
expect(finalSession!.state.isStoppedAtStep18()).toBe(true);
|
||||
expect(finalSession!.currentStep.value).toBe(18);
|
||||
expect(finalSession!.currentStep.value).toBe(17);
|
||||
expect(finalSession!.startedAt).toBeInstanceOf(Date);
|
||||
expect(finalSession!.completedAt).toBeInstanceOf(Date);
|
||||
expect(finalSession!.errorMessage).toBeUndefined();
|
||||
@@ -129,7 +129,7 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => {
|
||||
const sessions = await repository.findAll();
|
||||
finalSession = sessions[0] ?? null;
|
||||
|
||||
if (finalSession && (finalSession.state.isFailed() || finalSession.state.isStoppedAtStep18())) {
|
||||
if (finalSession && finalSession.state.isFailed()) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -143,11 +143,7 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => {
|
||||
await failingAdapter.disconnect();
|
||||
|
||||
expect(finalSession).not.toBeNull();
|
||||
expect(
|
||||
finalSession!.state.isFailed() || finalSession!.state.isStoppedAtStep18(),
|
||||
).toBe(true);
|
||||
if (finalSession!.state.isFailed()) {
|
||||
expect(finalSession!.errorMessage).toBeDefined();
|
||||
}
|
||||
expect(finalSession!.state.isFailed()).toBe(true);
|
||||
expect(finalSession!.errorMessage).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { PlaywrightAutomationAdapter, FixtureServer } from 'packages/infrastructure/adapters/automation';
|
||||
import {
|
||||
PlaywrightAutomationAdapter,
|
||||
FixtureServer,
|
||||
} from 'packages/infrastructure/adapters/automation';
|
||||
import { StepId } from 'packages/domain/value-objects/StepId';
|
||||
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Workflow – steps 7–9 cars flow (fixture-backed)', () => {
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
@@ -18,7 +22,7 @@ describe('Workflow – steps 7–9 cars flow (fixture-backed)', () => {
|
||||
timeout: 5000,
|
||||
baseUrl,
|
||||
mode: 'mock',
|
||||
}
|
||||
},
|
||||
);
|
||||
await adapter.connect();
|
||||
});
|
||||
@@ -28,7 +32,7 @@ describe('Workflow – steps 7–9 cars flow (fixture-backed)', () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
it('executes time limits, cars, and add car in sequence using fixtures', async () => {
|
||||
it('executes time limits, cars, and add car in sequence using fixtures and leaves JSON-backed state', async () => {
|
||||
await adapter.navigateToPage(server.getFixtureUrl(7));
|
||||
const step7Result = await adapter.executeStep(StepId.create(7), {
|
||||
practice: 10,
|
||||
@@ -37,14 +41,43 @@ describe('Workflow – steps 7–9 cars flow (fixture-backed)', () => {
|
||||
});
|
||||
expect(step7Result.success).toBe(true);
|
||||
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const raceSlider = page!
|
||||
.locator(IRACING_SELECTORS.steps.race)
|
||||
.first();
|
||||
const raceSliderValue =
|
||||
(await raceSlider.getAttribute('data-value')) ??
|
||||
(await raceSlider.inputValue().catch(() => null));
|
||||
expect(raceSliderValue).toBe('20');
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(8));
|
||||
const step8Result = await adapter.executeStep(StepId.create(8), {});
|
||||
expect(step8Result.success).toBe(true);
|
||||
|
||||
const carsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.cars)
|
||||
.first();
|
||||
expect(await carsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const addCarButton = page!
|
||||
.locator(IRACING_SELECTORS.steps.addCarButton)
|
||||
.first();
|
||||
expect(await addCarButton.count()).toBeGreaterThan(0);
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(9));
|
||||
const step9Result = await adapter.executeStep(StepId.create(9), {
|
||||
carSearch: 'Porsche 911 GT3 R',
|
||||
carSearch: 'Acura ARX-06',
|
||||
});
|
||||
expect(step9Result.success).toBe(true);
|
||||
|
||||
const carsTable = page!
|
||||
.locator('#select-car-set-cars table.table.table-striped')
|
||||
.first();
|
||||
expect(await carsTable.count()).toBeGreaterThan(0);
|
||||
|
||||
const acuraCell = carsTable.locator('tbody tr td >> text=Acura ARX-06 GTP');
|
||||
expect(await acuraCell.count()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -9,18 +9,19 @@ import * as path from 'path';
|
||||
* and runtime configuration via BrowserModeConfigLoader.
|
||||
*/
|
||||
|
||||
// Mock interfaces - will be replaced with actual imports in GREEN phase
|
||||
interface PlaywrightAutomationAdapter {
|
||||
type BrowserModeSource = 'env' | 'file' | 'default';
|
||||
|
||||
interface PlaywrightAutomationAdapterLike {
|
||||
connect(): Promise<{ success: boolean; error?: string }>;
|
||||
disconnect(): Promise<void>;
|
||||
isConnected(): boolean;
|
||||
getBrowserMode(): 'headed' | 'headless';
|
||||
getBrowserModeSource(): 'GUI' | 'NODE_ENV';
|
||||
getBrowserModeSource(): BrowserModeSource;
|
||||
}
|
||||
|
||||
describe('Browser Mode Integration - GREEN Phase', () => {
|
||||
const originalEnv = process.env;
|
||||
let adapter: PlaywrightAutomationAdapter | null = null;
|
||||
let adapter: PlaywrightAutomationAdapterLike | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
|
||||
@@ -9,15 +9,8 @@ import { CheckoutStateEnum } from '../../../packages/domain/value-objects/Checko
|
||||
* Tests verify HTML parsing for checkout price extraction and state detection.
|
||||
*/
|
||||
|
||||
interface Page {
|
||||
locator(selector: string): Locator;
|
||||
}
|
||||
|
||||
interface Locator {
|
||||
getAttribute(name: string): Promise<string | null>;
|
||||
innerHTML(): Promise<string>;
|
||||
textContent(): Promise<string | null>;
|
||||
}
|
||||
type Page = ConstructorParameters<typeof CheckoutPriceExtractor>[0];
|
||||
type Locator = ReturnType<Page['locator']>;
|
||||
|
||||
describe('CheckoutPriceExtractor Integration', () => {
|
||||
let mockPage: Page;
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
|
||||
const state = CheckoutState.ready();
|
||||
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.ok({ price, state })
|
||||
Result.ok({ price, state, buttonHtml: '' })
|
||||
);
|
||||
|
||||
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
||||
@@ -60,7 +60,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
|
||||
const state = CheckoutState.ready();
|
||||
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.ok({ price, state })
|
||||
Result.ok({ price, state, buttonHtml: '' })
|
||||
);
|
||||
|
||||
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
||||
@@ -80,7 +80,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
|
||||
const state = CheckoutState.ready();
|
||||
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.ok({ price, state })
|
||||
Result.ok({ price, state, buttonHtml: '' })
|
||||
);
|
||||
|
||||
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
||||
@@ -100,7 +100,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
|
||||
const state = CheckoutState.ready();
|
||||
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.ok({ price, state })
|
||||
Result.ok({ price, state, buttonHtml: '' })
|
||||
);
|
||||
|
||||
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
||||
@@ -118,7 +118,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
|
||||
const state = CheckoutState.insufficientFunds();
|
||||
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.ok({ price, state })
|
||||
Result.ok({ price, state, buttonHtml: '' })
|
||||
);
|
||||
|
||||
const result = await useCase.execute();
|
||||
@@ -139,7 +139,7 @@ describe('ConfirmCheckoutUseCase - Enhanced with Confirmation Port', () => {
|
||||
};
|
||||
|
||||
vi.mocked(mockCheckoutService.extractCheckoutInfo).mockResolvedValue(
|
||||
Result.ok({ price, state })
|
||||
Result.ok({ price, state, buttonHtml: '' })
|
||||
);
|
||||
|
||||
vi.mocked(mockConfirmationPort.requestCheckoutConfirmation).mockResolvedValue(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import { StartAutomationSessionUseCase } from '../../../../packages/application/use-cases/StartAutomationSessionUseCase';
|
||||
import { IAutomationEngine } from '../../../../packages/application/ports/IAutomationEngine';
|
||||
import { IBrowserAutomation } from '../../../../packages/application/ports/IBrowserAutomation';
|
||||
import { IScreenAutomation } from '../../../../packages/application/ports/IScreenAutomation';
|
||||
import { ISessionRepository } from '../../../../packages/application/ports/ISessionRepository';
|
||||
import { AutomationSession } from '../../../../packages/domain/entities/AutomationSession';
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('StartAutomationSessionUseCase', () => {
|
||||
|
||||
useCase = new StartAutomationSessionUseCase(
|
||||
mockAutomationEngine as unknown as IAutomationEngine,
|
||||
mockBrowserAutomation as unknown as IBrowserAutomation,
|
||||
mockBrowserAutomation as unknown as IScreenAutomation,
|
||||
mockSessionRepository as unknown as ISessionRepository
|
||||
);
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('SessionCookieStore - Cookie Validation', () => {
|
||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr()).toMatch(/domain mismatch/i);
|
||||
expect(result.unwrapErr().message).toMatch(/domain mismatch/i);
|
||||
});
|
||||
|
||||
test('should fail when cookie path is invalid for target', async () => {
|
||||
@@ -81,7 +81,7 @@ describe('SessionCookieStore - Cookie Validation', () => {
|
||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr()).toMatch(/path.*not valid/i);
|
||||
expect(result.unwrapErr().message).toMatch(/path.*not valid/i);
|
||||
});
|
||||
|
||||
test('should fail when required irsso_members cookie is missing', async () => {
|
||||
@@ -102,7 +102,7 @@ describe('SessionCookieStore - Cookie Validation', () => {
|
||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr()).toMatch(/required.*irsso_members/i);
|
||||
expect(result.unwrapErr().message).toMatch(/required.*irsso_members/i);
|
||||
});
|
||||
|
||||
test('should fail when required authtoken_members cookie is missing', async () => {
|
||||
@@ -123,14 +123,14 @@ describe('SessionCookieStore - Cookie Validation', () => {
|
||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr()).toMatch(/required.*authtoken_members/i);
|
||||
expect(result.unwrapErr().message).toMatch(/required.*authtoken_members/i);
|
||||
});
|
||||
|
||||
test('should fail when no cookies are stored', () => {
|
||||
const result = cookieStore.validateCookieConfiguration(targetUrl);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr()).toMatch(/no cookies/i);
|
||||
expect(result.unwrapErr().message).toMatch(/no cookies/i);
|
||||
});
|
||||
|
||||
test('should validate cookies for members-ng.iracing.com domain', async () => {
|
||||
|
||||
Reference in New Issue
Block a user