feat(automation): implement NODE_ENV-based automation mode with fixture server

This commit is contained in:
2025-11-22 16:37:32 +01:00
parent d4fa7afc6f
commit 78fc323e43
8 changed files with 763 additions and 44 deletions

View File

@@ -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';
}
}

View File

@@ -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,

View File

@@ -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.
*/

View File

@@ -5,5 +5,7 @@
export {
AutomationMode,
AutomationEnvironmentConfig,
FixtureServerConfig,
loadAutomationConfig,
getAutomationMode,
} from './AutomationConfig';