393 lines
14 KiB
TypeScript
393 lines
14 KiB
TypeScript
// 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';
|
||
|
||
/**
|
||
* E2E Tests for REAL Automation against REAL iRacing Website
|
||
*
|
||
* ZERO MOCKS - REAL AUTOMATION against the REAL iRacing website.
|
||
*
|
||
* 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
|
||
*/
|
||
|
||
const IRACING_URL = 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions';
|
||
const WINDOW_TITLE_PATTERN = 'iRacing';
|
||
|
||
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;
|
||
}
|
||
|
||
describe('E2E Real Automation Tests - 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);
|
||
});
|
||
});
|
||
}); |