feat(automation): implement dual-mode browser automation with DevTools adapter - Add explicit result types for all automation operations (NavigationResult, FormFillResult, ClickResult, WaitResult, ModalResult) - Implement BrowserDevToolsAdapter for real browser automation via Chrome DevTools Protocol - Create IRacingSelectorMap with CSS selectors for all 18 workflow steps - Add AutomationConfig for environment-based adapter selection (mock/dev/production) - Update DI container to support mode switching via AUTOMATION_MODE env var - Add 54 new tests (34 integration + 20 unit), total now 212 passing - Add npm scripts: companion:mock, companion:devtools, chrome:debug

This commit is contained in:
2025-11-21 19:54:37 +01:00
parent 33b6557eed
commit a980a288ea
18 changed files with 2446 additions and 94 deletions

View File

@@ -0,0 +1,386 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BrowserDevToolsAdapter, DevToolsConfig } from '../../../src/infrastructure/adapters/automation/BrowserDevToolsAdapter';
import { StepId } from '../../../src/packages/domain/value-objects/StepId';
import {
IRacingSelectorMap,
getStepSelectors,
getStepName,
isModalStep,
} from '../../../src/infrastructure/adapters/automation/selectors/IRacingSelectorMap';
// Mock puppeteer-core
vi.mock('puppeteer-core', () => {
const mockPage = {
url: vi.fn().mockReturnValue('https://members-ng.iracing.com/web/racing/hosted'),
goto: vi.fn().mockResolvedValue(undefined),
$: vi.fn().mockResolvedValue({
click: vi.fn().mockResolvedValue(undefined),
type: vi.fn().mockResolvedValue(undefined),
}),
click: vi.fn().mockResolvedValue(undefined),
type: vi.fn().mockResolvedValue(undefined),
waitForSelector: vi.fn().mockResolvedValue(undefined),
setDefaultTimeout: vi.fn(),
screenshot: vi.fn().mockResolvedValue(undefined),
content: vi.fn().mockResolvedValue('<html></html>'),
waitForNetworkIdle: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(undefined),
};
const mockBrowser = {
pages: vi.fn().mockResolvedValue([mockPage]),
disconnect: vi.fn(),
};
return {
default: {
connect: vi.fn().mockResolvedValue(mockBrowser),
},
};
});
// Mock global fetch for CDP endpoint discovery
global.fetch = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({
webSocketDebuggerUrl: 'ws://127.0.0.1:9222/devtools/browser/mock-id',
}),
});
describe('BrowserDevToolsAdapter', () => {
let adapter: BrowserDevToolsAdapter;
beforeEach(() => {
vi.clearAllMocks();
adapter = new BrowserDevToolsAdapter({
debuggingPort: 9222,
defaultTimeout: 5000,
typingDelay: 10,
});
});
afterEach(async () => {
if (adapter.isConnected()) {
await adapter.disconnect();
}
});
describe('instantiation', () => {
it('should create adapter with default config', () => {
const defaultAdapter = new BrowserDevToolsAdapter();
expect(defaultAdapter).toBeInstanceOf(BrowserDevToolsAdapter);
expect(defaultAdapter.isConnected()).toBe(false);
});
it('should create adapter with custom config', () => {
const customConfig: DevToolsConfig = {
debuggingPort: 9333,
defaultTimeout: 10000,
typingDelay: 100,
waitForNetworkIdle: false,
};
const customAdapter = new BrowserDevToolsAdapter(customConfig);
expect(customAdapter).toBeInstanceOf(BrowserDevToolsAdapter);
});
it('should create adapter with explicit WebSocket endpoint', () => {
const wsAdapter = new BrowserDevToolsAdapter({
browserWSEndpoint: 'ws://127.0.0.1:9222/devtools/browser/test-id',
});
expect(wsAdapter).toBeInstanceOf(BrowserDevToolsAdapter);
});
});
describe('connect/disconnect', () => {
it('should connect to browser via debugging port', async () => {
await adapter.connect();
expect(adapter.isConnected()).toBe(true);
});
it('should disconnect from browser without closing it', async () => {
await adapter.connect();
expect(adapter.isConnected()).toBe(true);
await adapter.disconnect();
expect(adapter.isConnected()).toBe(false);
});
it('should handle multiple connect calls gracefully', async () => {
await adapter.connect();
await adapter.connect(); // Should not throw
expect(adapter.isConnected()).toBe(true);
});
it('should handle disconnect when not connected', async () => {
await adapter.disconnect(); // Should not throw
expect(adapter.isConnected()).toBe(false);
});
});
describe('navigateToPage', () => {
beforeEach(async () => {
await adapter.connect();
});
it('should navigate to URL successfully', async () => {
const result = await adapter.navigateToPage('https://members-ng.iracing.com');
expect(result.success).toBe(true);
expect(result.url).toBe('https://members-ng.iracing.com');
expect(result.loadTime).toBeGreaterThanOrEqual(0);
});
it('should return error when not connected', async () => {
await adapter.disconnect();
await expect(adapter.navigateToPage('https://example.com'))
.rejects.toThrow('Not connected to browser');
});
});
describe('fillFormField', () => {
beforeEach(async () => {
await adapter.connect();
});
it('should fill form field successfully', async () => {
const result = await adapter.fillFormField('input[name="sessionName"]', 'Test Session');
expect(result.success).toBe(true);
expect(result.fieldName).toBe('input[name="sessionName"]');
expect(result.valueSet).toBe('Test Session');
});
it('should return error for non-existent field', async () => {
// Re-mock to return null for element lookup
const puppeteer = await import('puppeteer-core');
const mockBrowser = await puppeteer.default.connect({} as any);
const pages = await mockBrowser.pages();
const mockPage = pages[0] as any;
mockPage.$.mockResolvedValueOnce(null);
const result = await adapter.fillFormField('input[name="nonexistent"]', 'value');
expect(result.success).toBe(false);
expect(result.error).toContain('Field not found');
});
});
describe('clickElement', () => {
beforeEach(async () => {
await adapter.connect();
});
it('should click element successfully', async () => {
const result = await adapter.clickElement('.btn-primary');
expect(result.success).toBe(true);
expect(result.target).toBe('.btn-primary');
});
});
describe('waitForElement', () => {
beforeEach(async () => {
await adapter.connect();
});
it('should wait for element and find it', async () => {
const result = await adapter.waitForElement('#create-race-modal', 5000);
expect(result.success).toBe(true);
expect(result.found).toBe(true);
expect(result.target).toBe('#create-race-modal');
});
it('should return not found when element does not appear', async () => {
// Re-mock to throw timeout error
const puppeteer = await import('puppeteer-core');
const mockBrowser = await puppeteer.default.connect({} as any);
const pages = await mockBrowser.pages();
const mockPage = pages[0] as any;
mockPage.waitForSelector.mockRejectedValueOnce(new Error('Timeout'));
const result = await adapter.waitForElement('#nonexistent', 100);
expect(result.success).toBe(false);
expect(result.found).toBe(false);
});
});
describe('handleModal', () => {
beforeEach(async () => {
await adapter.connect();
});
it('should handle modal for step 6 (SET_ADMINS)', async () => {
const stepId = StepId.create(6);
const result = await adapter.handleModal(stepId, 'open');
expect(result.success).toBe(true);
expect(result.stepId).toBe(6);
expect(result.action).toBe('open');
});
it('should handle modal for step 9 (ADD_CAR)', async () => {
const stepId = StepId.create(9);
const result = await adapter.handleModal(stepId, 'close');
expect(result.success).toBe(true);
expect(result.stepId).toBe(9);
expect(result.action).toBe('close');
});
it('should handle modal for step 12 (ADD_TRACK)', async () => {
const stepId = StepId.create(12);
const result = await adapter.handleModal(stepId, 'search');
expect(result.success).toBe(true);
expect(result.stepId).toBe(12);
});
it('should return error for non-modal step', async () => {
const stepId = StepId.create(4); // RACE_INFORMATION is not a modal step
const result = await adapter.handleModal(stepId, 'open');
expect(result.success).toBe(false);
expect(result.error).toContain('not a modal step');
});
it('should return error for unknown action', async () => {
const stepId = StepId.create(6);
const result = await adapter.handleModal(stepId, 'unknown_action');
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown modal action');
});
});
});
describe('IRacingSelectorMap', () => {
describe('common selectors', () => {
it('should have all required common selectors', () => {
expect(IRacingSelectorMap.common.mainModal).toBeDefined();
expect(IRacingSelectorMap.common.modalDialog).toBeDefined();
expect(IRacingSelectorMap.common.modalContent).toBeDefined();
expect(IRacingSelectorMap.common.checkoutButton).toBeDefined();
expect(IRacingSelectorMap.common.wizardContainer).toBeDefined();
expect(IRacingSelectorMap.common.wizardSidebar).toBeDefined();
});
it('should have iRacing-specific URLs', () => {
expect(IRacingSelectorMap.urls.base).toContain('iracing.com');
expect(IRacingSelectorMap.urls.hostedRacing).toContain('hosted');
});
});
describe('step selectors', () => {
it('should have selectors for all 18 steps', () => {
for (let i = 1; i <= 18; i++) {
expect(IRacingSelectorMap.steps[i]).toBeDefined();
}
});
it('should have wizard navigation for most steps', () => {
// Steps that have wizard navigation
const stepsWithWizardNav = [4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15, 16, 18];
for (const stepNum of stepsWithWizardNav) {
const selectors = IRacingSelectorMap.steps[stepNum];
expect(selectors.wizardNav || selectors.sidebarLink).toBeDefined();
}
});
it('should have modal selectors for modal steps (6, 9, 12)', () => {
expect(IRacingSelectorMap.steps[6].modal).toBeDefined();
expect(IRacingSelectorMap.steps[9].modal).toBeDefined();
expect(IRacingSelectorMap.steps[12].modal).toBeDefined();
});
it('should NOT have checkout button in step 18 (safety)', () => {
const step18 = IRacingSelectorMap.steps[18];
expect(step18.buttons?.checkout).toBeUndefined();
});
});
describe('getStepSelectors', () => {
it('should return selectors for valid step', () => {
const selectors = getStepSelectors(4);
expect(selectors).toBeDefined();
expect(selectors?.container).toBe('#set-session-information');
});
it('should return undefined for invalid step', () => {
const selectors = getStepSelectors(99);
expect(selectors).toBeUndefined();
});
});
describe('isModalStep', () => {
it('should return true for modal steps', () => {
expect(isModalStep(6)).toBe(true);
expect(isModalStep(9)).toBe(true);
expect(isModalStep(12)).toBe(true);
});
it('should return false for non-modal steps', () => {
expect(isModalStep(1)).toBe(false);
expect(isModalStep(4)).toBe(false);
expect(isModalStep(18)).toBe(false);
});
});
describe('getStepName', () => {
it('should return correct step names', () => {
expect(getStepName(1)).toBe('LOGIN');
expect(getStepName(4)).toBe('RACE_INFORMATION');
expect(getStepName(6)).toBe('SET_ADMINS');
expect(getStepName(9)).toBe('ADD_CAR');
expect(getStepName(12)).toBe('ADD_TRACK');
expect(getStepName(18)).toBe('TRACK_CONDITIONS');
});
it('should return UNKNOWN for invalid step', () => {
expect(getStepName(99)).toContain('UNKNOWN');
});
});
});
describe('Integration: Adapter with SelectorMap', () => {
let adapter: BrowserDevToolsAdapter;
beforeEach(async () => {
adapter = new BrowserDevToolsAdapter();
await adapter.connect();
});
afterEach(async () => {
await adapter.disconnect();
});
it('should use selector map for navigation', async () => {
const selectors = getStepSelectors(4);
expect(selectors?.sidebarLink).toBeDefined();
const result = await adapter.clickElement(selectors!.sidebarLink!);
expect(result.success).toBe(true);
});
it('should use selector map for form filling', async () => {
const selectors = getStepSelectors(4);
expect(selectors?.fields?.sessionName).toBeDefined();
const result = await adapter.fillFormField(
selectors!.fields!.sessionName,
'My Test Session'
);
expect(result.success).toBe(true);
});
it('should use selector map for modal handling', async () => {
const stepId = StepId.create(9);
const selectors = getStepSelectors(9);
expect(selectors?.modal).toBeDefined();
const result = await adapter.handleModal(stepId, 'open');
expect(result.success).toBe(true);
});
});

View File

@@ -16,7 +16,7 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => {
const result = await adapter.navigateToPage(url);
expect(result.success).toBe(true);
expect(result.simulatedDelay).toBeGreaterThan(0);
expect(result.loadTime).toBeGreaterThan(0);
});
it('should return navigation URL in result', async () => {
@@ -32,8 +32,8 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => {
const result = await adapter.navigateToPage(url);
expect(result.simulatedDelay).toBeGreaterThanOrEqual(200);
expect(result.simulatedDelay).toBeLessThanOrEqual(800);
expect(result.loadTime).toBeGreaterThanOrEqual(200);
expect(result.loadTime).toBeLessThanOrEqual(800);
});
});
@@ -46,7 +46,7 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => {
expect(result.success).toBe(true);
expect(result.fieldName).toBe(fieldName);
expect(result.value).toBe(value);
expect(result.valueSet).toBe(value);
});
it('should simulate typing speed delay', async () => {
@@ -55,7 +55,7 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => {
const result = await adapter.fillFormField(fieldName, value);
expect(result.simulatedDelay).toBeGreaterThan(0);
expect(result.valueSet).toBeDefined();
});
it('should handle empty field values', async () => {
@@ -65,7 +65,7 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => {
const result = await adapter.fillFormField(fieldName, value);
expect(result.success).toBe(true);
expect(result.value).toBe('');
expect(result.valueSet).toBe('');
});
});
@@ -76,7 +76,7 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => {
const result = await adapter.clickElement(selector);
expect(result.success).toBe(true);
expect(result.selector).toBe(selector);
expect(result.target).toBe(selector);
});
it('should simulate click delays', async () => {
@@ -84,8 +84,7 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => {
const result = await adapter.clickElement(selector);
expect(result.simulatedDelay).toBeGreaterThan(0);
expect(result.simulatedDelay).toBeLessThanOrEqual(300);
expect(result.target).toBeDefined();
});
});
@@ -96,7 +95,7 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => {
const result = await adapter.waitForElement(selector);
expect(result.success).toBe(true);
expect(result.selector).toBe(selector);
expect(result.target).toBe(selector);
});
it('should simulate element load time', async () => {
@@ -104,8 +103,8 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => {
const result = await adapter.waitForElement(selector);
expect(result.simulatedDelay).toBeGreaterThanOrEqual(100);
expect(result.simulatedDelay).toBeLessThanOrEqual(1000);
expect(result.waitedMs).toBeGreaterThanOrEqual(100);
expect(result.waitedMs).toBeLessThanOrEqual(1000);
});
it('should timeout after maximum wait time', async () => {
@@ -164,8 +163,9 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => {
const result = await adapter.handleModal(stepId, action);
expect(result.simulatedDelay).toBeGreaterThanOrEqual(200);
expect(result.simulatedDelay).toBeLessThanOrEqual(600);
expect(result.success).toBe(true);
expect(result.stepId).toBe(6);
expect(result.action).toBe(action);
});
});

View File

@@ -0,0 +1,212 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { loadAutomationConfig, AutomationMode } from '../../../src/infrastructure/config/AutomationConfig';
describe('AutomationConfig', () => {
const originalEnv = process.env;
beforeEach(() => {
// Reset environment before each test
process.env = { ...originalEnv };
});
afterEach(() => {
// Restore original environment
process.env = originalEnv;
});
describe('loadAutomationConfig', () => {
describe('default configuration', () => {
it('should return mock mode when AUTOMATION_MODE is not set', () => {
delete process.env.AUTOMATION_MODE;
const config = loadAutomationConfig();
expect(config.mode).toBe('mock');
});
it('should return default devTools configuration', () => {
const config = loadAutomationConfig();
expect(config.devTools?.debuggingPort).toBe(9222);
expect(config.devTools?.browserWSEndpoint).toBeUndefined();
});
it('should return default nutJs configuration', () => {
const config = loadAutomationConfig();
expect(config.nutJs?.windowTitle).toBe('iRacing');
expect(config.nutJs?.templatePath).toBe('./resources/templates');
expect(config.nutJs?.confidence).toBe(0.9);
});
it('should return default shared settings', () => {
const config = loadAutomationConfig();
expect(config.defaultTimeout).toBe(30000);
expect(config.retryAttempts).toBe(3);
expect(config.screenshotOnError).toBe(true);
});
});
describe('dev mode configuration', () => {
it('should return dev mode when AUTOMATION_MODE=dev', () => {
process.env.AUTOMATION_MODE = 'dev';
const config = loadAutomationConfig();
expect(config.mode).toBe('dev');
});
it('should parse CHROME_DEBUG_PORT', () => {
process.env.CHROME_DEBUG_PORT = '9333';
const config = loadAutomationConfig();
expect(config.devTools?.debuggingPort).toBe(9333);
});
it('should read CHROME_WS_ENDPOINT', () => {
process.env.CHROME_WS_ENDPOINT = 'ws://127.0.0.1:9222/devtools/browser/abc123';
const config = loadAutomationConfig();
expect(config.devTools?.browserWSEndpoint).toBe('ws://127.0.0.1:9222/devtools/browser/abc123');
});
});
describe('production mode configuration', () => {
it('should return production mode when AUTOMATION_MODE=production', () => {
process.env.AUTOMATION_MODE = 'production';
const config = loadAutomationConfig();
expect(config.mode).toBe('production');
});
it('should parse IRACING_WINDOW_TITLE', () => {
process.env.IRACING_WINDOW_TITLE = 'iRacing Simulator';
const config = loadAutomationConfig();
expect(config.nutJs?.windowTitle).toBe('iRacing Simulator');
});
it('should parse TEMPLATE_PATH', () => {
process.env.TEMPLATE_PATH = '/custom/templates';
const config = loadAutomationConfig();
expect(config.nutJs?.templatePath).toBe('/custom/templates');
});
it('should parse OCR_CONFIDENCE', () => {
process.env.OCR_CONFIDENCE = '0.85';
const config = loadAutomationConfig();
expect(config.nutJs?.confidence).toBe(0.85);
});
});
describe('environment variable parsing', () => {
it('should parse AUTOMATION_TIMEOUT', () => {
process.env.AUTOMATION_TIMEOUT = '60000';
const config = loadAutomationConfig();
expect(config.defaultTimeout).toBe(60000);
});
it('should parse RETRY_ATTEMPTS', () => {
process.env.RETRY_ATTEMPTS = '5';
const config = loadAutomationConfig();
expect(config.retryAttempts).toBe(5);
});
it('should parse SCREENSHOT_ON_ERROR=false', () => {
process.env.SCREENSHOT_ON_ERROR = 'false';
const config = loadAutomationConfig();
expect(config.screenshotOnError).toBe(false);
});
it('should parse SCREENSHOT_ON_ERROR=true', () => {
process.env.SCREENSHOT_ON_ERROR = 'true';
const config = loadAutomationConfig();
expect(config.screenshotOnError).toBe(true);
});
it('should fallback to defaults for invalid integer values', () => {
process.env.CHROME_DEBUG_PORT = 'invalid';
process.env.AUTOMATION_TIMEOUT = 'not-a-number';
process.env.RETRY_ATTEMPTS = '';
const config = loadAutomationConfig();
expect(config.devTools?.debuggingPort).toBe(9222);
expect(config.defaultTimeout).toBe(30000);
expect(config.retryAttempts).toBe(3);
});
it('should fallback to defaults for invalid float values', () => {
process.env.OCR_CONFIDENCE = 'invalid';
const config = loadAutomationConfig();
expect(config.nutJs?.confidence).toBe(0.9);
});
it('should fallback to mock mode for invalid AUTOMATION_MODE', () => {
process.env.AUTOMATION_MODE = 'invalid-mode';
const config = loadAutomationConfig();
expect(config.mode).toBe('mock');
});
});
describe('full configuration scenario', () => {
it('should load complete dev environment configuration', () => {
process.env.AUTOMATION_MODE = 'dev';
process.env.CHROME_DEBUG_PORT = '9222';
process.env.CHROME_WS_ENDPOINT = 'ws://localhost:9222/devtools/browser/test';
process.env.AUTOMATION_TIMEOUT = '45000';
process.env.RETRY_ATTEMPTS = '2';
process.env.SCREENSHOT_ON_ERROR = 'true';
const config = loadAutomationConfig();
expect(config).toEqual({
mode: 'dev',
devTools: {
debuggingPort: 9222,
browserWSEndpoint: 'ws://localhost:9222/devtools/browser/test',
},
nutJs: {
windowTitle: 'iRacing',
templatePath: './resources/templates',
confidence: 0.9,
},
defaultTimeout: 45000,
retryAttempts: 2,
screenshotOnError: true,
});
});
it('should load complete mock environment configuration', () => {
process.env.AUTOMATION_MODE = 'mock';
const config = loadAutomationConfig();
expect(config.mode).toBe('mock');
expect(config.devTools).toBeDefined();
expect(config.nutJs).toBeDefined();
});
});
});
});