Files
gridpilot.gg/tests/e2e/automation.e2e.test.ts

393 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});
});
});