From 78fc323e43eb9533aa7ef251db6eecb79ed2ec95 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sat, 22 Nov 2025 16:37:32 +0100 Subject: [PATCH] feat(automation): implement NODE_ENV-based automation mode with fixture server --- apps/companion/main/di-container.ts | 131 ++++++++-- .../automation/FixtureServerService.ts | 166 +++++++++++++ .../adapters/automation/index.ts | 3 + .../infrastructure/config/AutomationConfig.ts | 92 ++++++- packages/infrastructure/config/index.ts | 2 + .../infrastructure/AutomationConfig.test.ts | 233 ++++++++++++++++-- .../FixtureServerService.test.ts | 174 +++++++++++++ vitest.config.ts | 6 + 8 files changed, 763 insertions(+), 44 deletions(-) create mode 100644 packages/infrastructure/adapters/automation/FixtureServerService.ts create mode 100644 tests/unit/infrastructure/FixtureServerService.test.ts diff --git a/apps/companion/main/di-container.ts b/apps/companion/main/di-container.ts index 19b324462..12939162d 100644 --- a/apps/companion/main/di-container.ts +++ b/apps/companion/main/di-container.ts @@ -4,8 +4,9 @@ import { BrowserDevToolsAdapter } from '@/packages/infrastructure/adapters/autom import { NutJsAutomationAdapter } from '@/packages/infrastructure/adapters/automation/NutJsAutomationAdapter'; import { MockAutomationEngineAdapter } from '@/packages/infrastructure/adapters/automation/MockAutomationEngineAdapter'; import { PermissionService } from '@/packages/infrastructure/adapters/automation/PermissionService'; +import { FixtureServerService } from '@/packages/infrastructure/adapters/automation/FixtureServerService'; import { StartAutomationSessionUseCase } from '@/packages/application/use-cases/StartAutomationSessionUseCase'; -import { loadAutomationConfig, AutomationMode } from '@/packages/infrastructure/config'; +import { loadAutomationConfig, getAutomationMode, AutomationMode } from '@/packages/infrastructure/config'; import { PinoLogAdapter } from '@/packages/infrastructure/adapters/logging/PinoLogAdapter'; import { NoOpLogAdapter } from '@/packages/infrastructure/adapters/logging/NoOpLogAdapter'; import { loadLoggingConfig } from '@/packages/infrastructure/config/LoggingConfig'; @@ -13,6 +14,7 @@ import type { ISessionRepository } from '@/packages/application/ports/ISessionRe import type { IBrowserAutomation } from '@/packages/application/ports/IBrowserAutomation'; import type { IAutomationEngine } from '@/packages/application/ports/IAutomationEngine'; import type { ILogger } from '@/packages/application/ports/ILogger'; +import type { IFixtureServerService } from '@/packages/infrastructure/adapters/automation/FixtureServerService'; export interface BrowserConnectionResult { success: boolean; @@ -36,6 +38,11 @@ function createLogger(): ILogger { /** * Create browser automation adapter based on configuration mode. * + * Mode mapping: + * - 'development' → BrowserDevToolsAdapter with fixture server URL + * - 'production' → NutJsAutomationAdapter with iRacing window + * - 'test' → MockBrowserAutomationAdapter + * * @param mode - The automation mode from configuration * @param logger - Logger instance for the adapter * @returns IBrowserAutomation adapter instance @@ -44,13 +51,14 @@ function createBrowserAutomationAdapter(mode: AutomationMode, logger: ILogger): const config = loadAutomationConfig(); switch (mode) { - case 'dev': + case 'development': return new BrowserDevToolsAdapter({ debuggingPort: config.devTools?.debuggingPort ?? 9222, browserWSEndpoint: config.devTools?.browserWSEndpoint, defaultTimeout: config.defaultTimeout, launchBrowser: true, - startUrl: 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions', + headless: false, + startUrl: `http://localhost:${config.fixtureServer?.port ?? 3456}/01-hosted-racing.html`, }, logger.child({ adapter: 'BrowserDevTools' })); case 'production': @@ -60,7 +68,7 @@ function createBrowserAutomationAdapter(mode: AutomationMode, logger: ILogger): defaultTimeout: config.defaultTimeout, }, logger.child({ adapter: 'NutJs' })); - case 'mock': + case 'test': default: return new MockBrowserAutomationAdapter(); } @@ -76,14 +84,20 @@ export class DIContainer { private startAutomationUseCase: StartAutomationSessionUseCase; private automationMode: AutomationMode; private permissionService: PermissionService; + private fixtureServer: IFixtureServerService | null = null; + private fixtureServerInitialized: boolean = false; private constructor() { // Initialize logger first - it's needed by other components this.logger = createLogger(); - this.logger.info('DIContainer initializing', { automationMode: process.env.AUTOMATION_MODE }); + + this.automationMode = getAutomationMode(); + this.logger.info('DIContainer initializing', { + automationMode: this.automationMode, + nodeEnv: process.env.NODE_ENV + }); const config = loadAutomationConfig(); - this.automationMode = config.mode; this.sessionRepository = new InMemorySessionRepository(); this.browserAutomation = createBrowserAutomationAdapter(config.mode, this.logger); @@ -109,8 +123,9 @@ export class DIContainer { private getBrowserAutomationType(mode: AutomationMode): string { switch (mode) { - case 'dev': return 'BrowserDevToolsAdapter'; + case 'development': return 'BrowserDevToolsAdapter'; case 'production': return 'NutJsAutomationAdapter'; + case 'test': default: return 'MockBrowserAutomationAdapter'; } } @@ -151,22 +166,73 @@ export class DIContainer { } /** - * Initialize browser connection for dev mode. - * In dev mode, connects to the browser via Chrome DevTools Protocol. - * In mock mode, returns success immediately (no connection needed). + * Initialize fixture server for development mode. + * Starts an embedded HTTP server serving static HTML fixtures. + * This should be called before initializing browser connection. + */ + private async initializeFixtureServer(): Promise { + const config = loadAutomationConfig(); + + if (!config.fixtureServer?.autoStart) { + this.logger.debug('Fixture server auto-start disabled'); + return { success: true }; + } + + if (this.fixtureServerInitialized) { + this.logger.debug('Fixture server already initialized'); + return { success: true }; + } + + this.fixtureServer = new FixtureServerService(); + const port = config.fixtureServer.port; + const fixturesPath = config.fixtureServer.fixturesPath; + + try { + await this.fixtureServer.start(port, fixturesPath); + const isReady = await this.fixtureServer.waitForReady(5000); + + if (!isReady) { + throw new Error('Fixture server failed to become ready within timeout'); + } + + this.fixtureServerInitialized = true; + this.logger.info(`Fixture server started on port ${port}`, { + port, + fixturesPath, + baseUrl: this.fixtureServer.getBaseUrl() + }); + + return { success: true }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Failed to start fixture server'; + this.logger.error('Fixture server initialization failed', error instanceof Error ? error : new Error(errorMsg)); + return { success: false, error: errorMsg }; + } + } + + /** + * Initialize browser connection based on mode. + * In development mode, starts fixture server (if configured) then connects to browser via CDP. + * In production mode, connects to iRacing window via nut.js. + * In test mode, returns success immediately (no connection needed). */ public async initializeBrowserConnection(): Promise { this.logger.info('Initializing browser connection', { mode: this.automationMode }); - if (this.automationMode === 'dev') { + if (this.automationMode === 'development') { + const fixtureResult = await this.initializeFixtureServer(); + if (!fixtureResult.success) { + return fixtureResult; + } + try { const devToolsAdapter = this.browserAutomation as BrowserDevToolsAdapter; await devToolsAdapter.connect(); - this.logger.info('Browser connection established', { mode: 'dev', adapter: 'BrowserDevTools' }); + this.logger.info('Browser connection established', { mode: 'development', adapter: 'BrowserDevTools' }); return { success: true }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Failed to connect to browser'; - this.logger.error('Browser connection failed', error instanceof Error ? error : new Error(errorMsg), { mode: 'dev' }); + this.logger.error('Browser connection failed', error instanceof Error ? error : new Error(errorMsg), { mode: 'development' }); return { success: false, error: errorMsg @@ -192,8 +258,43 @@ export class DIContainer { }; } } - this.logger.debug('Mock mode - no browser connection needed'); - return { success: true }; // Mock mode doesn't need connection + this.logger.debug('Test mode - no browser connection needed'); + return { success: true }; // Test mode doesn't need connection + } + + /** + * Get the fixture server instance (may be null if not in development mode or not auto-started). + */ + public getFixtureServer(): IFixtureServerService | null { + return this.fixtureServer; + } + + /** + * Shutdown the container and cleanup resources. + * Should be called when the application is closing. + */ + public async shutdown(): Promise { + this.logger.info('DIContainer shutting down'); + + if (this.fixtureServer?.isRunning()) { + try { + await this.fixtureServer.stop(); + this.logger.info('Fixture server stopped'); + } catch (error) { + this.logger.error('Error stopping fixture server', error instanceof Error ? error : new Error('Unknown error')); + } + } + + if (this.browserAutomation && 'disconnect' in this.browserAutomation) { + try { + await (this.browserAutomation as BrowserDevToolsAdapter).disconnect(); + this.logger.info('Browser automation disconnected'); + } catch (error) { + this.logger.error('Error disconnecting browser automation', error instanceof Error ? error : new Error('Unknown error')); + } + } + + this.logger.info('DIContainer shutdown complete'); } /** diff --git a/packages/infrastructure/adapters/automation/FixtureServerService.ts b/packages/infrastructure/adapters/automation/FixtureServerService.ts new file mode 100644 index 000000000..a817cf76b --- /dev/null +++ b/packages/infrastructure/adapters/automation/FixtureServerService.ts @@ -0,0 +1,166 @@ +import * as http from 'http'; +import * as fs from 'fs'; +import * as path from 'path'; + +export interface IFixtureServerService { + start(port: number, fixturesPath: string): Promise; + stop(): Promise; + waitForReady(timeoutMs: number): Promise; + getBaseUrl(): string; + isRunning(): boolean; +} + +export class FixtureServerService implements IFixtureServerService { + private server: http.Server | null = null; + private port: number = 3456; + private resolvedFixturesPath: string = ''; + + async start(port: number, fixturesPath: string): Promise { + if (this.server) { + throw new Error('Fixture server is already running'); + } + + this.port = port; + this.resolvedFixturesPath = path.resolve(fixturesPath); + + if (!fs.existsSync(this.resolvedFixturesPath)) { + throw new Error(`Fixtures path does not exist: ${this.resolvedFixturesPath}`); + } + + return new Promise((resolve, reject) => { + this.server = http.createServer((req, res) => { + this.handleRequest(req, res); + }); + + this.server.on('error', (error) => { + this.server = null; + reject(error); + }); + + this.server.listen(this.port, () => { + resolve(); + }); + }); + } + + async stop(): Promise { + if (!this.server) { + return; + } + + return new Promise((resolve, reject) => { + this.server!.close((error) => { + this.server = null; + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + + async waitForReady(timeoutMs: number = 5000): Promise { + const startTime = Date.now(); + const pollInterval = 100; + + while (Date.now() - startTime < timeoutMs) { + const isReady = await this.checkHealth(); + if (isReady) { + return true; + } + await this.sleep(pollInterval); + } + + return false; + } + + getBaseUrl(): string { + return `http://localhost:${this.port}`; + } + + isRunning(): boolean { + return this.server !== null; + } + + private async checkHealth(): Promise { + return new Promise((resolve) => { + const req = http.get(`${this.getBaseUrl()}/health`, (res) => { + resolve(res.statusCode === 200); + }); + + req.on('error', () => { + resolve(false); + }); + + req.setTimeout(1000, () => { + req.destroy(); + resolve(false); + }); + }); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + const url = req.url || '/'; + + res.setHeader('Access-Control-Allow-Origin', '*'); + + if (url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + return; + } + + const sanitizedUrl = url.startsWith('/') ? url.slice(1) : url; + const filePath = path.join(this.resolvedFixturesPath, sanitizedUrl || 'index.html'); + + const normalizedFilePath = path.normalize(filePath); + if (!normalizedFilePath.startsWith(this.resolvedFixturesPath)) { + res.writeHead(403, { 'Content-Type': 'text/plain' }); + res.end('Forbidden'); + return; + } + + fs.stat(normalizedFilePath, (statErr, stats) => { + if (statErr || !stats.isFile()) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + return; + } + + const ext = path.extname(normalizedFilePath).toLowerCase(); + const contentType = this.getContentType(ext); + + fs.readFile(normalizedFilePath, (readErr, data) => { + if (readErr) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Internal Server Error'); + return; + } + + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + }); + }); + } + + private getContentType(ext: string): string { + const mimeTypes: Record = { + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + }; + + return mimeTypes[ext] || 'application/octet-stream'; + } +} \ No newline at end of file diff --git a/packages/infrastructure/adapters/automation/index.ts b/packages/infrastructure/adapters/automation/index.ts index 51aff5d01..05130c134 100644 --- a/packages/infrastructure/adapters/automation/index.ts +++ b/packages/infrastructure/adapters/automation/index.ts @@ -17,6 +17,9 @@ export { NutJsAutomationAdapter, NutJsConfig } from './NutJsAutomationAdapter'; // Permission service export { PermissionService, PermissionStatus, PermissionCheckResult } from './PermissionService'; +// Fixture server +export { FixtureServerService, IFixtureServerService } from './FixtureServerService'; + // Selector map and utilities export { IRacingSelectorMap, diff --git a/packages/infrastructure/config/AutomationConfig.ts b/packages/infrastructure/config/AutomationConfig.ts index 400277d14..aeeec04d2 100644 --- a/packages/infrastructure/config/AutomationConfig.ts +++ b/packages/infrastructure/config/AutomationConfig.ts @@ -1,16 +1,32 @@ /** * Automation configuration module for environment-based adapter selection. - * + * * This module provides configuration types and loaders for the automation system, - * allowing switching between different adapters based on environment variables. + * allowing switching between different adapters based on NODE_ENV. + * + * Mapping: + * - NODE_ENV=development → BrowserDevToolsAdapter → Fixture Server → CSS Selectors + * - NODE_ENV=production → NutJsAutomationAdapter → iRacing Window → Image Templates + * - NODE_ENV=test → MockBrowserAutomation → N/A → N/A */ -export type AutomationMode = 'dev' | 'production' | 'mock'; +export type AutomationMode = 'development' | 'production' | 'test'; + +/** + * @deprecated Use AutomationMode instead. Will be removed in future version. + */ +export type LegacyAutomationMode = 'dev' | 'production' | 'mock'; + +export interface FixtureServerConfig { + port: number; + autoStart: boolean; + fixturesPath: string; +} export interface AutomationEnvironmentConfig { mode: AutomationMode; - /** Dev mode configuration (Browser DevTools) */ + /** Development mode configuration (Browser DevTools with fixture server) */ devTools?: { browserWSEndpoint?: string; debuggingPort?: number; @@ -25,6 +41,9 @@ export interface AutomationEnvironmentConfig { confidence?: number; }; + /** Fixture server configuration for development mode */ + fixtureServer?: FixtureServerConfig; + /** Default timeout for automation operations in milliseconds */ defaultTimeout?: number; /** Number of retry attempts for failed operations */ @@ -33,11 +52,41 @@ export interface AutomationEnvironmentConfig { screenshotOnError?: boolean; } +/** + * Get the automation mode based on NODE_ENV. + * + * Mapping: + * - NODE_ENV=production → 'production' + * - NODE_ENV=test → 'test' + * - NODE_ENV=development → 'development' (default) + * + * For backward compatibility, if AUTOMATION_MODE is explicitly set, + * it will be used with a deprecation warning logged to console. + * + * @returns AutomationMode derived from NODE_ENV + */ +export function getAutomationMode(): AutomationMode { + const legacyMode = process.env.AUTOMATION_MODE; + if (legacyMode && isValidLegacyAutomationMode(legacyMode)) { + console.warn( + `[DEPRECATED] AUTOMATION_MODE environment variable is deprecated. ` + + `Use NODE_ENV instead. Mapping: dev→development, mock→test, production→production` + ); + return mapLegacyMode(legacyMode); + } + + const nodeEnv = process.env.NODE_ENV; + if (nodeEnv === 'production') return 'production'; + if (nodeEnv === 'test') return 'test'; + return 'development'; +} + /** * Load automation configuration from environment variables. - * + * * Environment variables: - * - AUTOMATION_MODE: 'dev' | 'production' | 'mock' (default: 'mock') + * - NODE_ENV: 'development' | 'production' | 'test' (default: 'development') + * - AUTOMATION_MODE: (deprecated) 'dev' | 'production' | 'mock' * - CHROME_DEBUG_PORT: Chrome debugging port (default: 9222) * - CHROME_WS_ENDPOINT: WebSocket endpoint for Chrome DevTools * - IRACING_WINDOW_TITLE: Window title for nut.js (default: 'iRacing') @@ -46,12 +95,14 @@ export interface AutomationEnvironmentConfig { * - AUTOMATION_TIMEOUT: Default timeout in ms (default: 30000) * - RETRY_ATTEMPTS: Number of retry attempts (default: 3) * - SCREENSHOT_ON_ERROR: Capture screenshots on error (default: true) - * + * - FIXTURE_SERVER_PORT: Port for fixture server (default: 3456) + * - FIXTURE_SERVER_AUTO_START: Auto-start fixture server (default: true in development) + * - FIXTURE_SERVER_PATH: Path to fixtures (default: './resources/iracing-hosted-sessions') + * * @returns AutomationEnvironmentConfig with parsed environment values */ export function loadAutomationConfig(): AutomationEnvironmentConfig { - const modeEnv = process.env.AUTOMATION_MODE; - const mode: AutomationMode = isValidAutomationMode(modeEnv) ? modeEnv : 'mock'; + const mode = getAutomationMode(); return { mode, @@ -66,6 +117,11 @@ export function loadAutomationConfig(): AutomationEnvironmentConfig { templatePath: process.env.TEMPLATE_PATH || './resources/templates', confidence: parseFloatSafe(process.env.OCR_CONFIDENCE, 0.9), }, + fixtureServer: { + port: parseIntSafe(process.env.FIXTURE_SERVER_PORT, 3456), + autoStart: process.env.FIXTURE_SERVER_AUTO_START !== 'false' && mode === 'development', + fixturesPath: process.env.FIXTURE_SERVER_PATH || './resources/iracing-hosted-sessions', + }, defaultTimeout: parseIntSafe(process.env.AUTOMATION_TIMEOUT, 30000), retryAttempts: parseIntSafe(process.env.RETRY_ATTEMPTS, 3), screenshotOnError: process.env.SCREENSHOT_ON_ERROR !== 'false', @@ -76,9 +132,27 @@ export function loadAutomationConfig(): AutomationEnvironmentConfig { * Type guard to validate automation mode string. */ function isValidAutomationMode(value: string | undefined): value is AutomationMode { + return value === 'development' || value === 'production' || value === 'test'; +} + +/** + * Type guard to validate legacy automation mode string. + */ +function isValidLegacyAutomationMode(value: string | undefined): value is LegacyAutomationMode { return value === 'dev' || value === 'production' || value === 'mock'; } +/** + * Map legacy automation mode to new mode. + */ +function mapLegacyMode(legacy: LegacyAutomationMode): AutomationMode { + switch (legacy) { + case 'dev': return 'development'; + case 'mock': return 'test'; + case 'production': return 'production'; + } +} + /** * Safely parse an integer with a default fallback. */ diff --git a/packages/infrastructure/config/index.ts b/packages/infrastructure/config/index.ts index 3508df142..943888cd9 100644 --- a/packages/infrastructure/config/index.ts +++ b/packages/infrastructure/config/index.ts @@ -5,5 +5,7 @@ export { AutomationMode, AutomationEnvironmentConfig, + FixtureServerConfig, loadAutomationConfig, + getAutomationMode, } from './AutomationConfig'; \ No newline at end of file diff --git a/tests/unit/infrastructure/AutomationConfig.test.ts b/tests/unit/infrastructure/AutomationConfig.test.ts index ac7bcf724..012d574a6 100644 --- a/tests/unit/infrastructure/AutomationConfig.test.ts +++ b/tests/unit/infrastructure/AutomationConfig.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { loadAutomationConfig, AutomationMode } from '../../../packages/infrastructure/config/AutomationConfig'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { loadAutomationConfig, getAutomationMode, AutomationMode } from '../../../packages/infrastructure/config/AutomationConfig'; describe('AutomationConfig', () => { const originalEnv = process.env; @@ -14,14 +14,114 @@ describe('AutomationConfig', () => { process.env = originalEnv; }); + describe('getAutomationMode', () => { + describe('NODE_ENV-based mode detection', () => { + it('should return development mode when NODE_ENV=development', () => { + process.env.NODE_ENV = 'development'; + delete process.env.AUTOMATION_MODE; + + const mode = getAutomationMode(); + + expect(mode).toBe('development'); + }); + + it('should return production mode when NODE_ENV=production', () => { + process.env.NODE_ENV = 'production'; + delete process.env.AUTOMATION_MODE; + + const mode = getAutomationMode(); + + expect(mode).toBe('production'); + }); + + it('should return test mode when NODE_ENV=test', () => { + process.env.NODE_ENV = 'test'; + delete process.env.AUTOMATION_MODE; + + const mode = getAutomationMode(); + + expect(mode).toBe('test'); + }); + + it('should return development mode when NODE_ENV is not set', () => { + delete process.env.NODE_ENV; + delete process.env.AUTOMATION_MODE; + + const mode = getAutomationMode(); + + expect(mode).toBe('development'); + }); + + it('should return development mode for unknown NODE_ENV values', () => { + process.env.NODE_ENV = 'staging'; + delete process.env.AUTOMATION_MODE; + + const mode = getAutomationMode(); + + expect(mode).toBe('development'); + }); + }); + + describe('legacy AUTOMATION_MODE support', () => { + it('should map legacy dev mode to development with deprecation warning', () => { + process.env.AUTOMATION_MODE = 'dev'; + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const mode = getAutomationMode(); + + expect(mode).toBe('development'); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[DEPRECATED] AUTOMATION_MODE') + ); + consoleSpy.mockRestore(); + }); + + it('should map legacy mock mode to test with deprecation warning', () => { + process.env.AUTOMATION_MODE = 'mock'; + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const mode = getAutomationMode(); + + expect(mode).toBe('test'); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[DEPRECATED] AUTOMATION_MODE') + ); + consoleSpy.mockRestore(); + }); + + it('should map legacy production mode to production with deprecation warning', () => { + process.env.AUTOMATION_MODE = 'production'; + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const mode = getAutomationMode(); + + expect(mode).toBe('production'); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[DEPRECATED] AUTOMATION_MODE') + ); + consoleSpy.mockRestore(); + }); + + it('should ignore invalid AUTOMATION_MODE and use NODE_ENV', () => { + process.env.AUTOMATION_MODE = 'invalid-mode'; + process.env.NODE_ENV = 'production'; + + const mode = getAutomationMode(); + + expect(mode).toBe('production'); + }); + }); + }); + describe('loadAutomationConfig', () => { describe('default configuration', () => { - it('should return mock mode when AUTOMATION_MODE is not set', () => { + it('should return development mode when NODE_ENV is not set', () => { + delete process.env.NODE_ENV; delete process.env.AUTOMATION_MODE; const config = loadAutomationConfig(); - expect(config.mode).toBe('mock'); + expect(config.mode).toBe('development'); }); it('should return default devTools configuration', () => { @@ -46,15 +146,27 @@ describe('AutomationConfig', () => { 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'; + it('should return default fixture server configuration', () => { + delete process.env.NODE_ENV; + delete process.env.AUTOMATION_MODE; const config = loadAutomationConfig(); - expect(config.mode).toBe('dev'); + expect(config.fixtureServer?.port).toBe(3456); + expect(config.fixtureServer?.autoStart).toBe(true); + expect(config.fixtureServer?.fixturesPath).toBe('./resources/iracing-hosted-sessions'); + }); + }); + + describe('development mode configuration', () => { + it('should return development mode when NODE_ENV=development', () => { + process.env.NODE_ENV = 'development'; + delete process.env.AUTOMATION_MODE; + + const config = loadAutomationConfig(); + + expect(config.mode).toBe('development'); }); it('should parse CHROME_DEBUG_PORT', () => { @@ -75,8 +187,9 @@ describe('AutomationConfig', () => { }); describe('production mode configuration', () => { - it('should return production mode when AUTOMATION_MODE=production', () => { - process.env.AUTOMATION_MODE = 'production'; + it('should return production mode when NODE_ENV=production', () => { + process.env.NODE_ENV = 'production'; + delete process.env.AUTOMATION_MODE; const config = loadAutomationConfig(); @@ -161,28 +274,87 @@ describe('AutomationConfig', () => { expect(config.nutJs?.confidence).toBe(0.9); }); - it('should fallback to mock mode for invalid AUTOMATION_MODE', () => { - process.env.AUTOMATION_MODE = 'invalid-mode'; + it('should fallback to development mode for invalid NODE_ENV', () => { + process.env.NODE_ENV = 'invalid-env'; + delete process.env.AUTOMATION_MODE; const config = loadAutomationConfig(); - expect(config.mode).toBe('mock'); + expect(config.mode).toBe('development'); + }); + }); + + describe('fixture server configuration', () => { + it('should auto-start fixture server in development mode', () => { + process.env.NODE_ENV = 'development'; + delete process.env.AUTOMATION_MODE; + + const config = loadAutomationConfig(); + + expect(config.fixtureServer?.autoStart).toBe(true); + }); + + it('should not auto-start fixture server in production mode', () => { + process.env.NODE_ENV = 'production'; + delete process.env.AUTOMATION_MODE; + + const config = loadAutomationConfig(); + + expect(config.fixtureServer?.autoStart).toBe(false); + }); + + it('should not auto-start fixture server in test mode', () => { + process.env.NODE_ENV = 'test'; + delete process.env.AUTOMATION_MODE; + + const config = loadAutomationConfig(); + + expect(config.fixtureServer?.autoStart).toBe(false); + }); + + it('should parse FIXTURE_SERVER_PORT', () => { + process.env.FIXTURE_SERVER_PORT = '4567'; + + const config = loadAutomationConfig(); + + expect(config.fixtureServer?.port).toBe(4567); + }); + + it('should parse FIXTURE_SERVER_PATH', () => { + process.env.FIXTURE_SERVER_PATH = '/custom/fixtures'; + + const config = loadAutomationConfig(); + + expect(config.fixtureServer?.fixturesPath).toBe('/custom/fixtures'); + }); + + it('should respect FIXTURE_SERVER_AUTO_START=false', () => { + process.env.NODE_ENV = 'development'; + process.env.FIXTURE_SERVER_AUTO_START = 'false'; + delete process.env.AUTOMATION_MODE; + + const config = loadAutomationConfig(); + + expect(config.fixtureServer?.autoStart).toBe(false); }); }); describe('full configuration scenario', () => { - it('should load complete dev environment configuration', () => { - process.env.AUTOMATION_MODE = 'dev'; + it('should load complete development environment configuration', () => { + process.env.NODE_ENV = 'development'; + delete process.env.AUTOMATION_MODE; 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'; + process.env.FIXTURE_SERVER_PORT = '3456'; + process.env.FIXTURE_SERVER_PATH = './resources/iracing-hosted-sessions'; const config = loadAutomationConfig(); expect(config).toEqual({ - mode: 'dev', + mode: 'development', devTools: { debuggingPort: 9222, browserWSEndpoint: 'ws://localhost:9222/devtools/browser/test', @@ -194,20 +366,41 @@ describe('AutomationConfig', () => { templatePath: './resources/templates', confidence: 0.9, }, + fixtureServer: { + port: 3456, + autoStart: true, + fixturesPath: './resources/iracing-hosted-sessions', + }, defaultTimeout: 45000, retryAttempts: 2, screenshotOnError: true, }); }); - it('should load complete mock environment configuration', () => { - process.env.AUTOMATION_MODE = 'mock'; + it('should load complete test environment configuration', () => { + process.env.NODE_ENV = 'test'; + delete process.env.AUTOMATION_MODE; const config = loadAutomationConfig(); - expect(config.mode).toBe('mock'); + expect(config.mode).toBe('test'); expect(config.devTools).toBeDefined(); expect(config.nutJs).toBeDefined(); + expect(config.fixtureServer).toBeDefined(); + expect(config.fixtureServer?.autoStart).toBe(false); + }); + + it('should load complete production environment configuration', () => { + process.env.NODE_ENV = 'production'; + delete process.env.AUTOMATION_MODE; + + const config = loadAutomationConfig(); + + expect(config.mode).toBe('production'); + expect(config.devTools).toBeDefined(); + expect(config.nutJs).toBeDefined(); + expect(config.fixtureServer).toBeDefined(); + expect(config.fixtureServer?.autoStart).toBe(false); }); }); }); diff --git a/tests/unit/infrastructure/FixtureServerService.test.ts b/tests/unit/infrastructure/FixtureServerService.test.ts new file mode 100644 index 000000000..3cb5ba005 --- /dev/null +++ b/tests/unit/infrastructure/FixtureServerService.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as http from 'http'; +import * as path from 'path'; +import { FixtureServerService } from '@/packages/infrastructure/adapters/automation/FixtureServerService'; + +describe('FixtureServerService', () => { + let service: FixtureServerService; + let testPort: number; + const fixturesPath = './resources/iracing-hosted-sessions'; + + beforeEach(() => { + service = new FixtureServerService(); + testPort = 13400 + Math.floor(Math.random() * 100); + }); + + afterEach(async () => { + if (service.isRunning()) { + await service.stop(); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + describe('start', () => { + it('should start the server on specified port', async () => { + await service.start(testPort, fixturesPath); + + expect(service.isRunning()).toBe(true); + expect(service.getBaseUrl()).toBe(`http://localhost:${testPort}`); + }); + + it('should throw error if server is already running', async () => { + await service.start(testPort, fixturesPath); + + await expect(service.start(testPort, fixturesPath)).rejects.toThrow( + 'Fixture server is already running' + ); + }); + + it('should throw error if fixtures path does not exist', async () => { + await expect(service.start(testPort, './non-existent-path')).rejects.toThrow( + /Fixtures path does not exist/ + ); + }); + }); + + describe('stop', () => { + it('should stop a running server', async () => { + await service.start(testPort, fixturesPath); + expect(service.isRunning()).toBe(true); + + await service.stop(); + + expect(service.isRunning()).toBe(false); + }); + + it('should resolve when server is not running', async () => { + expect(service.isRunning()).toBe(false); + + await expect(service.stop()).resolves.toBeUndefined(); + }); + }); + + describe('waitForReady', () => { + it('should return true when server is ready', async () => { + await service.start(testPort, fixturesPath); + + const isReady = await service.waitForReady(5000); + + expect(isReady).toBe(true); + }); + + it('should return false when server is not running', async () => { + const isReady = await service.waitForReady(500); + + expect(isReady).toBe(false); + }); + }); + + describe('getBaseUrl', () => { + it('should return correct base URL with default port', () => { + expect(service.getBaseUrl()).toBe('http://localhost:3456'); + }); + + it('should return correct base URL after starting on custom port', async () => { + await service.start(testPort, fixturesPath); + + expect(service.getBaseUrl()).toBe(`http://localhost:${testPort}`); + }); + }); + + describe('isRunning', () => { + it('should return false when server is not started', () => { + expect(service.isRunning()).toBe(false); + }); + + it('should return true when server is running', async () => { + await service.start(testPort, fixturesPath); + + expect(service.isRunning()).toBe(true); + }); + }); + + describe('HTTP serving', () => { + it('should serve HTML files from fixtures path', async () => { + await service.start(testPort, fixturesPath); + + const response = await makeRequest(`http://localhost:${testPort}/01-hosted-racing.html`); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toBe('text/html'); + expect(response.body).toContain(' { + await service.start(testPort, fixturesPath); + + const response = await makeRequest(`http://localhost:${testPort}/health`); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toBe('application/json'); + expect(JSON.parse(response.body)).toEqual({ status: 'ok' }); + }); + + it('should return 404 for non-existent files', async () => { + await service.start(testPort, fixturesPath); + + const response = await makeRequest(`http://localhost:${testPort}/non-existent.html`); + + expect(response.statusCode).toBe(404); + }); + + it('should include CORS headers', async () => { + await service.start(testPort, fixturesPath); + + const response = await makeRequest(`http://localhost:${testPort}/health`); + + expect(response.headers['access-control-allow-origin']).toBe('*'); + }); + + it('should return 404 for path traversal attempts', async () => { + await service.start(testPort, fixturesPath); + + const response = await makeRequest(`http://localhost:${testPort}/../package.json`); + + expect([403, 404]).toContain(response.statusCode); + }); + }); +}); + +interface HttpResponse { + statusCode: number; + headers: Record; + body: string; +} + +function makeRequest(url: string): Promise { + return new Promise((resolve, reject) => { + http.get(url, (res) => { + let body = ''; + + res.on('data', (chunk) => { + body += chunk; + }); + + res.on('end', () => { + resolve({ + statusCode: res.statusCode || 0, + headers: res.headers as Record, + body, + }); + }); + }).on('error', reject); + }); +} \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index 0f4ce3108..13ad33b51 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from 'vitest/config'; +import path from 'path'; export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + }, + }, test: { globals: true, environment: 'node',