refactor(automation): remove browser automation, use OS-level automation only

This commit is contained in:
2025-11-22 17:57:35 +01:00
parent 99fa06e12b
commit 84800c663a
44 changed files with 110 additions and 5125 deletions

View File

@@ -1,422 +0,0 @@
/**
* E2E Tests for Automation Workflow
*
* IMPORTANT: These tests run in HEADLESS mode only.
* No headed browser tests are allowed per project requirements.
*
* Tests verify that the IRacingSelectorMap selectors correctly find
* elements in the static HTML fixture files.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import puppeteer, { Browser, Page } from 'puppeteer';
import { createServer, Server } from 'http';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import {
IRacingSelectorMap,
getStepSelectors,
getStepName,
isModalStep,
} from '../../packages/infrastructure/adapters/automation/selectors/IRacingSelectorMap';
// ==================== HEADLESS ENFORCEMENT ====================
const HEADLESS_MODE = true; // NEVER change this to false - headless only!
// ==================== Test Configuration ====================
const FIXTURES_DIR = join(process.cwd(), 'resources', 'iracing-hosted-sessions');
const TEST_PORT = 3456;
const TEST_BASE_URL = `http://localhost:${TEST_PORT}`;
// Map step numbers to fixture filenames
const STEP_TO_FIXTURE: Record<number, string> = {
2: '01-hosted-racing.html',
3: '02-create-a-race.html',
4: '03-race-information.html',
5: '04-server-details.html',
6: '05-set-admins.html',
7: '07-time-limits.html',
8: '08-set-cars.html',
9: '09-add-a-car.html',
10: '10-set-car-classes.html',
11: '11-set-track.html',
12: '12-add-a-track.html',
13: '13-track-options.html',
14: '14-time-of-day.html',
15: '15-weather.html',
16: '16-race-options.html',
17: '17-team-driving.html',
18: '18-track-conditions.html',
};
// ==================== HTTP Server for Fixtures ====================
function createFixtureServer(): Server {
return createServer((req, res) => {
const url = req.url || '/';
const filename = url.slice(1) || 'index.html';
const filepath = join(FIXTURES_DIR, filename);
if (existsSync(filepath)) {
const content = readFileSync(filepath, 'utf-8');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(content);
} else {
res.writeHead(404);
res.end('Not Found');
}
});
}
// ==================== Test Suite ====================
describe('E2E: Automation Workflow - HEADLESS MODE', () => {
let browser: Browser;
let page: Page;
let server: Server;
beforeAll(async () => {
// Start fixture server
server = createFixtureServer();
await new Promise<void>((resolve) => {
server.listen(TEST_PORT, () => {
console.log(`Fixture server running on port ${TEST_PORT}`);
resolve();
});
});
// Launch browser in HEADLESS mode - this is enforced and cannot be changed
browser = await puppeteer.launch({
headless: HEADLESS_MODE, // MUST be true - never run headed
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
],
});
}, 30000);
afterAll(async () => {
// Close browser
if (browser) {
await browser.close();
}
// Stop fixture server
if (server) {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
beforeEach(async () => {
page = await browser.newPage();
// Set viewport for consistent rendering
await page.setViewport({ width: 1920, height: 1080 });
});
afterEach(async () => {
if (page) {
await page.close();
}
});
// ==================== Headless Mode Verification ====================
describe('Headless Mode Verification', () => {
it('should be running in headless mode', async () => {
// This test verifies we're in headless mode
expect(HEADLESS_MODE).toBe(true);
// Additional check - headless browsers have specific user agent patterns
const userAgent = await page.evaluate(() => navigator.userAgent);
// Headless Chrome includes "HeadlessChrome" in newer versions
// or we can verify by checking we can't access certain headed-only features
expect(browser.connected).toBe(true);
});
});
// ==================== IRacingSelectorMap Tests ====================
describe('IRacingSelectorMap Structure', () => {
it('should have all 18 steps defined', () => {
for (let step = 1; step <= 18; step++) {
const selectors = getStepSelectors(step);
expect(selectors, `Step ${step} should have selectors`).toBeDefined();
}
});
it('should identify modal steps correctly', () => {
expect(isModalStep(6)).toBe(true); // SET_ADMINS
expect(isModalStep(9)).toBe(true); // ADD_CAR
expect(isModalStep(12)).toBe(true); // ADD_TRACK
expect(isModalStep(1)).toBe(false);
expect(isModalStep(18)).toBe(false);
});
it('should have correct step names', () => {
expect(getStepName(1)).toBe('LOGIN');
expect(getStepName(4)).toBe('RACE_INFORMATION');
expect(getStepName(9)).toBe('ADD_CAR');
expect(getStepName(18)).toBe('TRACK_CONDITIONS');
});
it('should NOT have checkout button in final step (safety)', () => {
const step18 = getStepSelectors(18);
expect(step18?.buttons?.checkout).toBeUndefined();
});
it('should have common selectors defined', () => {
expect(IRacingSelectorMap.common.mainModal).toBeDefined();
expect(IRacingSelectorMap.common.checkoutButton).toBeDefined();
expect(IRacingSelectorMap.common.wizardContainer).toBeDefined();
});
});
// ==================== Fixture Loading Tests ====================
describe('HTML Fixture Loading', () => {
it('should load hosted racing fixture (step 2)', async () => {
const fixture = STEP_TO_FIXTURE[2];
if (!fixture) {
console.log('Skipping: No fixture for step 2');
return;
}
await page.goto(`${TEST_BASE_URL}/${fixture}`, {
waitUntil: 'domcontentloaded',
timeout: 30000
});
const title = await page.title();
expect(title).toBeDefined();
}, 60000);
it('should load race information fixture (step 4)', async () => {
const fixture = STEP_TO_FIXTURE[4];
if (!fixture) {
console.log('Skipping: No fixture for step 4');
return;
}
await page.goto(`${TEST_BASE_URL}/${fixture}`, {
waitUntil: 'domcontentloaded',
timeout: 30000
});
const content = await page.content();
expect(content.length).toBeGreaterThan(0);
}, 60000);
});
// ==================== Selector Validation Tests ====================
describe('Selector Validation Against Fixtures', () => {
// Test common selectors that should exist in the wizard pages
const wizardSteps = [4, 5, 6, 7, 8, 10, 11, 13, 14, 15, 16, 17, 18];
for (const stepNum of wizardSteps) {
const fixture = STEP_TO_FIXTURE[stepNum];
if (!fixture) continue;
it(`should find wizard container in step ${stepNum} (${getStepName(stepNum)})`, async () => {
await page.goto(`${TEST_BASE_URL}/${fixture}`, {
waitUntil: 'domcontentloaded',
timeout: 60000
});
// Check for wizard-related elements using broad selectors
// The actual iRacing selectors may use dynamic classes, so we test for presence of expected patterns
const hasContent = await page.evaluate(() => {
return document.body.innerHTML.length > 0;
});
expect(hasContent).toBe(true);
}, 120000);
}
});
// ==================== Modal Step Tests ====================
describe('Modal Step Fixtures', () => {
it('should load add-an-admin fixture (step 6 modal)', async () => {
const fixture = '06-add-an-admin.html';
const filepath = join(FIXTURES_DIR, fixture);
if (!existsSync(filepath)) {
console.log('Skipping: Modal fixture not available');
return;
}
await page.goto(`${TEST_BASE_URL}/${fixture}`, {
waitUntil: 'domcontentloaded',
timeout: 60000
});
// Verify page loaded
const content = await page.content();
expect(content.length).toBeGreaterThan(1000);
}, 120000);
it('should load add-a-car fixture (step 9 modal)', async () => {
const fixture = STEP_TO_FIXTURE[9];
if (!fixture) {
console.log('Skipping: No fixture for step 9');
return;
}
await page.goto(`${TEST_BASE_URL}/${fixture}`, {
waitUntil: 'domcontentloaded',
timeout: 60000
});
const content = await page.content();
expect(content.length).toBeGreaterThan(1000);
}, 120000);
it('should load add-a-track fixture (step 12 modal)', async () => {
const fixture = STEP_TO_FIXTURE[12];
if (!fixture) {
console.log('Skipping: No fixture for step 12');
return;
}
await page.goto(`${TEST_BASE_URL}/${fixture}`, {
waitUntil: 'domcontentloaded',
timeout: 60000
});
const content = await page.content();
expect(content.length).toBeGreaterThan(1000);
}, 120000);
});
// ==================== Workflow Progression Tests ====================
describe('Workflow Step Progression', () => {
it('should have fixtures for complete 18-step workflow', () => {
// Verify we have fixtures for the workflow steps
const availableSteps = Object.keys(STEP_TO_FIXTURE).map(Number);
// We should have fixtures for steps 2-18 (step 1 is login, handled externally)
expect(availableSteps.length).toBeGreaterThanOrEqual(15);
// Check critical steps exist
expect(STEP_TO_FIXTURE[4]).toBeDefined(); // RACE_INFORMATION
expect(STEP_TO_FIXTURE[8]).toBeDefined(); // SET_CARS
expect(STEP_TO_FIXTURE[11]).toBeDefined(); // SET_TRACK
expect(STEP_TO_FIXTURE[18]).toBeDefined(); // TRACK_CONDITIONS
});
it('should have step 18 as final step (safety checkpoint)', () => {
const step18Selectors = getStepSelectors(18);
expect(step18Selectors).toBeDefined();
expect(getStepName(18)).toBe('TRACK_CONDITIONS');
// Verify checkout is NOT automated (safety)
expect(step18Selectors?.buttons?.checkout).toBeUndefined();
});
});
// ==================== Element Detection Tests ====================
describe('Element Detection in Fixtures', () => {
it('should detect form elements in race information page', async () => {
const fixture = STEP_TO_FIXTURE[4];
if (!fixture) {
console.log('Skipping: No fixture');
return;
}
await page.goto(`${TEST_BASE_URL}/${fixture}`, {
waitUntil: 'domcontentloaded',
timeout: 60000
});
// Check for input elements (the page should have form inputs)
const hasInputs = await page.evaluate(() => {
const inputs = document.querySelectorAll('input');
return inputs.length > 0;
});
expect(hasInputs).toBe(true);
}, 120000);
it('should detect buttons in fixture pages', async () => {
const fixture = STEP_TO_FIXTURE[5];
if (!fixture) {
console.log('Skipping: No fixture');
return;
}
await page.goto(`${TEST_BASE_URL}/${fixture}`, {
waitUntil: 'domcontentloaded',
timeout: 60000
});
// Check for button elements
const hasButtons = await page.evaluate(() => {
const buttons = document.querySelectorAll('button, .btn, [role="button"]');
return buttons.length > 0;
});
expect(hasButtons).toBe(true);
}, 120000);
});
// ==================== CSS Selector Syntax Tests ====================
describe('CSS Selector Syntax Validation', () => {
it('should have valid CSS selector syntax for common selectors', async () => {
// Test that selectors are syntactically valid by attempting to parse them in the browser
const selectorsToValidate = [
IRacingSelectorMap.common.mainModal,
IRacingSelectorMap.common.checkoutButton,
IRacingSelectorMap.common.wizardContainer,
];
// Load a minimal page to test selector syntax
await page.setContent('<html><body><div id="test"></div></body></html>');
for (const selector of selectorsToValidate) {
// Verify selector is a valid string
expect(typeof selector).toBe('string');
expect(selector.length).toBeGreaterThan(0);
// Test selector syntax in the browser context (won't throw for valid CSS)
const isValid = await page.evaluate((sel) => {
try {
document.querySelector(sel);
return true;
} catch {
return false;
}
}, selector);
expect(isValid, `Selector "${selector}" should be valid CSS`).toBe(true);
}
});
it('should have valid CSS selectors for each step', () => {
for (let step = 1; step <= 18; step++) {
const selectors = getStepSelectors(step);
if (selectors?.container) {
// Verify the selector string exists and is non-empty
expect(typeof selectors.container).toBe('string');
expect(selectors.container.length).toBeGreaterThan(0);
}
}
});
});
});
// ==================== Standalone Headless Verification ====================
describe('E2E: Headless Mode Enforcement', () => {
it('HEADLESS_MODE constant must be true', () => {
// This test will fail if anyone changes HEADLESS_MODE to false
expect(HEADLESS_MODE).toBe(true);
});
it('should never allow headed mode configuration', () => {
// Double-check that we're enforcing headless
const isHeadless = HEADLESS_MODE === true;
expect(isHeadless).toBe(true);
if (!isHeadless) {
throw new Error('CRITICAL: Headed mode is forbidden! Set HEADLESS_MODE=true');
}
});
});

View File

@@ -1,390 +0,0 @@
/**
* Docker-Based E2E Tests for BrowserDevToolsAdapter
*
* These tests run against real Docker containers:
* - browserless/chrome: Headless Chrome with CDP exposed
* - fixture-server: nginx serving static HTML fixtures
*
* Prerequisites:
* - Run `npm run docker:e2e:up` to start containers
* - Chrome available at ws://localhost:9222
* - Fixtures available at http://localhost:3456
*
* IMPORTANT: These tests use REAL adapters, no mocks.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { BrowserDevToolsAdapter } from '../../../packages/infrastructure/adapters/automation/BrowserDevToolsAdapter';
import { StepId } from '../../../packages/domain/value-objects/StepId';
import {
IRacingSelectorMap,
getStepSelectors,
getStepName,
} from '../../../packages/infrastructure/adapters/automation/selectors/IRacingSelectorMap';
// Environment configuration
const CHROME_WS_ENDPOINT = process.env.CHROME_WS_ENDPOINT || 'ws://localhost:9222';
const FIXTURE_BASE_URL = process.env.FIXTURE_BASE_URL || 'http://localhost:3456';
const DEFAULT_TIMEOUT = 30000;
// Map step numbers to fixture filenames
const STEP_TO_FIXTURE: Record<number, string> = {
2: '01-hosted-racing.html',
3: '02-create-a-race.html',
4: '03-race-information.html',
5: '04-server-details.html',
6: '05-set-admins.html',
7: '07-time-limits.html',
8: '08-set-cars.html',
9: '09-add-a-car.html',
10: '10-set-car-classes.html',
11: '11-set-track.html',
12: '12-add-a-track.html',
13: '13-track-options.html',
14: '14-time-of-day.html',
15: '15-weather.html',
16: '16-race-options.html',
17: '17-team-driving.html',
18: '18-track-conditions.html',
};
/**
* Helper to check if Docker environment is available
*/
async function isDockerEnvironmentReady(): Promise<boolean> {
try {
// Check if Chrome CDP is accessible
const chromeResponse = await fetch('http://localhost:9222/json/version');
if (!chromeResponse.ok) return false;
// Check if fixture server is accessible
const fixtureResponse = await fetch(`${FIXTURE_BASE_URL}/01-hosted-racing.html`);
if (!fixtureResponse.ok) return false;
return true;
} catch {
return false;
}
}
describe('E2E: BrowserDevToolsAdapter - Docker Environment', () => {
let adapter: BrowserDevToolsAdapter;
let dockerReady: boolean;
beforeAll(async () => {
// Check if Docker environment is available
dockerReady = await isDockerEnvironmentReady();
if (!dockerReady) {
console.warn(
'\n⚠ Docker E2E environment not ready.\n' +
' Run: npm run docker:e2e:up\n' +
' Skipping Docker E2E tests.\n'
);
return;
}
// Create adapter with CDP connection to Docker Chrome
adapter = new BrowserDevToolsAdapter({
browserWSEndpoint: CHROME_WS_ENDPOINT,
defaultTimeout: DEFAULT_TIMEOUT,
typingDelay: 10, // Fast typing for tests
waitForNetworkIdle: false, // Static fixtures don't need network idle
});
await adapter.connect();
}, 60000);
afterAll(async () => {
if (adapter?.isConnected()) {
await adapter.disconnect();
}
});
// ==================== Connection Tests ====================
describe('Browser Connection', () => {
it('should connect to Docker Chrome via CDP', () => {
if (!dockerReady) return;
expect(adapter.isConnected()).toBe(true);
});
it('should have a valid page after connection', () => {
if (!dockerReady) return;
const url = adapter.getCurrentUrl();
expect(url).toBeDefined();
});
});
// ==================== Navigation Tests ====================
describe('Navigation to Fixtures', () => {
it('should navigate to hosted racing page (step 2 fixture)', async () => {
if (!dockerReady) return;
const fixtureUrl = `${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[2]}`;
const result = await adapter.navigateToPage(fixtureUrl);
expect(result.success).toBe(true);
expect(result.url).toBe(fixtureUrl);
expect(result.loadTime).toBeGreaterThan(0);
});
it('should navigate to race information page (step 4 fixture)', async () => {
if (!dockerReady) return;
const fixtureUrl = `${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`;
const result = await adapter.navigateToPage(fixtureUrl);
expect(result.success).toBe(true);
expect(result.url).toBe(fixtureUrl);
});
it('should return error for non-existent page', async () => {
if (!dockerReady) return;
const result = await adapter.navigateToPage(`${FIXTURE_BASE_URL}/nonexistent.html`);
// Navigation may succeed but page returns 404
expect(result.success).toBe(true); // HTTP navigation succeeds
});
});
// ==================== Element Detection Tests ====================
describe('Element Detection in Fixtures', () => {
it('should detect elements exist on hosted racing page', async () => {
if (!dockerReady) return;
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[2]}`);
// Wait for any element to verify page loaded
const result = await adapter.waitForElement('body', 5000);
expect(result.success).toBe(true);
expect(result.found).toBe(true);
});
it('should detect form inputs on race information page', async () => {
if (!dockerReady) return;
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
// Check for input elements
const bodyResult = await adapter.waitForElement('body', 5000);
expect(bodyResult.success).toBe(true);
// Evaluate page content to verify inputs exist
const hasInputs = await adapter.evaluate(() => {
return document.querySelectorAll('input').length > 0;
});
expect(hasInputs).toBe(true);
});
it('should return not found for non-existent element', async () => {
if (!dockerReady) return;
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
const result = await adapter.waitForElement('#completely-nonexistent-element-xyz', 1000);
expect(result.success).toBe(false);
expect(result.found).toBe(false);
});
});
// ==================== Click Operation Tests ====================
describe('Click Operations', () => {
it('should click on visible elements', async () => {
if (!dockerReady) return;
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
// Try to click any button if available
const hasButtons = await adapter.evaluate(() => {
return document.querySelectorAll('button').length > 0;
});
if (hasButtons) {
const result = await adapter.clickElement('button');
// May fail if button not visible/clickable, but should not throw
expect(result).toBeDefined();
}
});
it('should return error when clicking non-existent element', async () => {
if (!dockerReady) return;
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
const result = await adapter.clickElement('#nonexistent-button');
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
// ==================== Form Fill Tests ====================
describe('Form Field Operations', () => {
it('should fill form field when input exists', async () => {
if (!dockerReady) return;
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
// Find first input on page
const hasInput = await adapter.evaluate(() => {
const input = document.querySelector('input[type="text"], input:not([type])');
return input !== null;
});
if (hasInput) {
const result = await adapter.fillFormField('input[type="text"], input:not([type])', 'Test Value');
// May succeed or fail depending on input visibility
expect(result).toBeDefined();
}
});
it('should return error when filling non-existent field', async () => {
if (!dockerReady) return;
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
const result = await adapter.fillFormField('#nonexistent-input', 'Test Value');
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
// ==================== Step Execution Tests ====================
describe('Step Execution', () => {
it('should execute step 1 (LOGIN) as skipped', async () => {
if (!dockerReady) return;
const stepId = StepId.create(1);
if (stepId.isFailure()) throw new Error('Invalid step ID');
const result = await adapter.executeStep(stepId.value, {});
expect(result.success).toBe(true);
expect(result.metadata?.skipped).toBe(true);
expect(result.metadata?.reason).toBe('User pre-authenticated');
});
it('should execute step 18 (TRACK_CONDITIONS) with safety stop', async () => {
if (!dockerReady) return;
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[18]}`);
const stepId = StepId.create(18);
if (stepId.isFailure()) throw new Error('Invalid step ID');
const result = await adapter.executeStep(stepId.value, {});
expect(result.success).toBe(true);
expect(result.metadata?.safetyStop).toBe(true);
expect(result.metadata?.step).toBe('TRACK_CONDITIONS');
});
});
// ==================== Selector Validation Tests ====================
describe('IRacingSelectorMap Validation', () => {
it('should have valid selectors that can be parsed by browser', async () => {
if (!dockerReady) return;
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
// Test common selectors for CSS validity
const selectorsToTest = [
IRacingSelectorMap.common.mainModal,
IRacingSelectorMap.common.wizardContainer,
IRacingSelectorMap.common.nextButton,
];
for (const selector of selectorsToTest) {
const isValid = await adapter.evaluate((sel) => {
try {
document.querySelector(sel);
return true;
} catch {
return false;
}
}, selector as unknown as () => boolean);
// We're testing selector syntax, not element presence
expect(typeof selector).toBe('string');
}
});
it('should have selectors defined for all 18 steps', () => {
for (let step = 1; step <= 18; step++) {
const selectors = getStepSelectors(step);
expect(selectors).toBeDefined();
expect(getStepName(step)).toBeDefined();
}
});
});
// ==================== Page Content Tests ====================
describe('Page Content Retrieval', () => {
it('should get page content from fixtures', async () => {
if (!dockerReady) return;
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
const content = await adapter.getPageContent();
expect(content).toBeDefined();
expect(content.length).toBeGreaterThan(0);
expect(content).toContain('<!DOCTYPE html>');
});
it('should evaluate JavaScript in page context', async () => {
if (!dockerReady) return;
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
const result = await adapter.evaluate(() => {
return {
title: document.title,
hasBody: document.body !== null,
};
});
expect(result.hasBody).toBe(true);
});
});
// ==================== Workflow Progression Tests ====================
describe('Workflow Navigation', () => {
it('should navigate through multiple fixtures sequentially', async () => {
if (!dockerReady) return;
// Navigate through first few steps
const steps = [2, 3, 4, 5];
for (const step of steps) {
const fixture = STEP_TO_FIXTURE[step];
if (!fixture) continue;
const result = await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${fixture}`);
expect(result.success).toBe(true);
// Verify page loaded
const bodyExists = await adapter.waitForElement('body', 5000);
expect(bodyExists.found).toBe(true);
}
});
});
});
// ==================== Standalone Skip Test ====================
describe('E2E Docker Environment Check', () => {
it('should report Docker environment status', async () => {
const ready = await isDockerEnvironmentReady();
if (ready) {
console.log('✅ Docker E2E environment is ready');
} else {
console.log('⚠️ Docker E2E environment not available');
console.log(' Start with: npm run docker:e2e:up');
}
// This test always passes - it's informational
expect(true).toBe(true);
});
});