feat(automation): implement NODE_ENV-based automation mode with fixture server
This commit is contained in:
@@ -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<BrowserConnectionResult> {
|
||||
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<BrowserConnectionResult> {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void>;
|
||||
stop(): Promise<void>;
|
||||
waitForReady(timeoutMs: number): Promise<boolean>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<string, string> = {
|
||||
'.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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -5,5 +5,7 @@
|
||||
export {
|
||||
AutomationMode,
|
||||
AutomationEnvironmentConfig,
|
||||
FixtureServerConfig,
|
||||
loadAutomationConfig,
|
||||
getAutomationMode,
|
||||
} from './AutomationConfig';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
174
tests/unit/infrastructure/FixtureServerService.test.ts
Normal file
174
tests/unit/infrastructure/FixtureServerService.test.ts
Normal file
@@ -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('<!DOCTYPE html');
|
||||
});
|
||||
|
||||
it('should serve health endpoint', async () => {
|
||||
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<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function makeRequest(url: string): Promise<HttpResponse> {
|
||||
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<string, string>,
|
||||
body,
|
||||
});
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user