refactor(automation): remove browser automation, use OS-level automation only
This commit is contained in:
@@ -1,50 +1,17 @@
|
||||
import { app } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { InMemorySessionRepository } from '@/packages/infrastructure/repositories/InMemorySessionRepository';
|
||||
import { MockBrowserAutomationAdapter } from '@/packages/infrastructure/adapters/automation/MockBrowserAutomationAdapter';
|
||||
import { BrowserDevToolsAdapter } from '@/packages/infrastructure/adapters/automation/BrowserDevToolsAdapter';
|
||||
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, 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';
|
||||
import type { ISessionRepository } from '@/packages/application/ports/ISessionRepository';
|
||||
import type { IBrowserAutomation } from '@/packages/application/ports/IBrowserAutomation';
|
||||
import type { IScreenAutomation } from '@/packages/application/ports/IScreenAutomation';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Resolve the fixtures path relative to the monorepo root.
|
||||
* Uses __dirname to determine the navigation depth based on whether we're
|
||||
* running from source (dev mode) or built output (dist mode).
|
||||
*
|
||||
* Path breakdown:
|
||||
* - Dev mode: apps/companion/main → 3 levels to root
|
||||
* - Built mode: apps/companion/dist/main → 4 levels to root
|
||||
*
|
||||
* @param configuredPath - The path configured (may be relative or absolute)
|
||||
* @param dirname - The directory name (__dirname) of the calling module
|
||||
* @returns Resolved absolute path
|
||||
*/
|
||||
export function resolveFixturesPath(configuredPath: string, dirname: string): string {
|
||||
if (path.isAbsolute(configuredPath)) {
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
// Determine navigation depth based on whether we're in dist/ or source
|
||||
// Dev mode: apps/companion/main → 3 levels to root
|
||||
// Built mode: apps/companion/dist/main → 4 levels to root
|
||||
const isBuiltMode = dirname.includes(`${path.sep}dist${path.sep}`);
|
||||
const levelsUp = isBuiltMode ? '../../../../' : '../../../';
|
||||
const projectRoot = path.resolve(dirname, levelsUp);
|
||||
|
||||
return path.join(projectRoot, configuredPath);
|
||||
}
|
||||
|
||||
export interface BrowserConnectionResult {
|
||||
success: boolean;
|
||||
@@ -66,37 +33,22 @@ function createLogger(): ILogger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create browser automation adapter based on configuration mode.
|
||||
* Create screen automation adapter based on configuration mode.
|
||||
*
|
||||
* Mode mapping:
|
||||
* - 'development' → BrowserDevToolsAdapter with fixture server URL
|
||||
* - 'production' → NutJsAutomationAdapter with iRacing window
|
||||
* - 'test' → MockBrowserAutomationAdapter
|
||||
* - 'test'/'development' → MockBrowserAutomationAdapter
|
||||
*
|
||||
* @param mode - The automation mode from configuration
|
||||
* @param logger - Logger instance for the adapter
|
||||
* @returns IBrowserAutomation adapter instance
|
||||
* @returns IScreenAutomation adapter instance
|
||||
*/
|
||||
function createBrowserAutomationAdapter(mode: AutomationMode, logger: ILogger): IBrowserAutomation {
|
||||
function createBrowserAutomationAdapter(mode: AutomationMode, logger: ILogger): IScreenAutomation {
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
switch (mode) {
|
||||
case 'development':
|
||||
return new BrowserDevToolsAdapter({
|
||||
debuggingPort: config.devTools?.debuggingPort ?? 9222,
|
||||
browserWSEndpoint: config.devTools?.browserWSEndpoint,
|
||||
defaultTimeout: config.defaultTimeout,
|
||||
launchBrowser: true,
|
||||
headless: false,
|
||||
startUrl: `http://localhost:${config.fixtureServer?.port ?? 3456}/01-hosted-racing.html`,
|
||||
}, logger.child({ adapter: 'BrowserDevTools' }));
|
||||
|
||||
case 'production':
|
||||
return new NutJsAutomationAdapter({
|
||||
mouseSpeed: config.nutJs?.mouseSpeed,
|
||||
keyboardDelay: config.nutJs?.keyboardDelay,
|
||||
defaultTimeout: config.defaultTimeout,
|
||||
}, logger.child({ adapter: 'NutJs' }));
|
||||
return new NutJsAutomationAdapter(config.nutJs, logger.child({ adapter: 'NutJs' }));
|
||||
|
||||
case 'test':
|
||||
default:
|
||||
@@ -109,13 +61,11 @@ export class DIContainer {
|
||||
|
||||
private logger: ILogger;
|
||||
private sessionRepository: ISessionRepository;
|
||||
private browserAutomation: IBrowserAutomation;
|
||||
private browserAutomation: IScreenAutomation;
|
||||
private automationEngine: IAutomationEngine;
|
||||
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
|
||||
@@ -153,7 +103,6 @@ export class DIContainer {
|
||||
|
||||
private getBrowserAutomationType(mode: AutomationMode): string {
|
||||
switch (mode) {
|
||||
case 'development': return 'BrowserDevToolsAdapter';
|
||||
case 'production': return 'NutJsAutomationAdapter';
|
||||
case 'test':
|
||||
default: return 'MockBrowserAutomationAdapter';
|
||||
@@ -183,7 +132,7 @@ export class DIContainer {
|
||||
return this.automationMode;
|
||||
}
|
||||
|
||||
public getBrowserAutomation(): IBrowserAutomation {
|
||||
public getBrowserAutomation(): IScreenAutomation {
|
||||
return this.browserAutomation;
|
||||
}
|
||||
|
||||
@@ -196,121 +145,35 @@ export class DIContainer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the fixtures path relative to the monorepo root.
|
||||
* Uses the exported resolveFixturesPath function with __dirname.
|
||||
*/
|
||||
private resolveFixturesPathInternal(configuredPath: string): string {
|
||||
return resolveFixturesPath(configuredPath, __dirname);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = this.resolveFixturesPathInternal(config.fixtureServer.fixturesPath);
|
||||
|
||||
this.logger.debug('Fixture server path resolution', {
|
||||
configuredPath: config.fixtureServer.fixturesPath,
|
||||
dirname: __dirname,
|
||||
resolvedPath: 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.
|
||||
* Initialize automation connection based on mode.
|
||||
* In production mode, connects to iRacing window via nut.js.
|
||||
* In test mode, returns success immediately (no connection needed).
|
||||
* In test/development mode, returns success immediately (no connection needed).
|
||||
*/
|
||||
public async initializeBrowserConnection(): Promise<BrowserConnectionResult> {
|
||||
this.logger.info('Initializing browser connection', { mode: this.automationMode });
|
||||
this.logger.info('Initializing automation connection', { mode: this.automationMode });
|
||||
|
||||
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: '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: 'development' });
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg
|
||||
};
|
||||
}
|
||||
}
|
||||
if (this.automationMode === 'production') {
|
||||
try {
|
||||
const nutJsAdapter = this.browserAutomation as NutJsAutomationAdapter;
|
||||
const result = await nutJsAdapter.connect();
|
||||
if (!result.success) {
|
||||
this.logger.error('Browser connection failed', new Error(result.error || 'Unknown error'), { mode: 'production' });
|
||||
this.logger.error('Automation connection failed', new Error(result.error || 'Unknown error'), { mode: 'production' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
this.logger.info('Browser connection established', { mode: 'production', adapter: 'NutJs' });
|
||||
this.logger.info('Automation connection established', { mode: 'production', adapter: 'NutJs' });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to initialize nut.js';
|
||||
this.logger.error('Browser connection failed', error instanceof Error ? error : new Error(errorMsg), { mode: 'production' });
|
||||
this.logger.error('Automation connection failed', error instanceof Error ? error : new Error(errorMsg), { mode: 'production' });
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg
|
||||
};
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
this.logger.debug('Test/development mode - no automation connection needed');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,21 +183,12 @@ export class DIContainer {
|
||||
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');
|
||||
await (this.browserAutomation as NutJsAutomationAdapter).disconnect();
|
||||
this.logger.info('Automation adapter disconnected');
|
||||
} catch (error) {
|
||||
this.logger.error('Error disconnecting browser automation', error instanceof Error ? error : new Error('Unknown error'));
|
||||
this.logger.error('Error disconnecting automation adapter', error instanceof Error ? error : new Error('Unknown error'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
chrome:
|
||||
image: browserless/chrome:latest
|
||||
ports:
|
||||
- "9222:3000"
|
||||
environment:
|
||||
- CONNECTION_TIMEOUT=120000
|
||||
- MAX_CONCURRENT_SESSIONS=5
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/json/version"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
fixture-server:
|
||||
build: ./fixture-server
|
||||
ports:
|
||||
- "3456:80"
|
||||
volumes:
|
||||
- ../resources/iracing-hosted-sessions:/usr/share/nginx/html:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost/01-hosted-racing.html"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -1,3 +0,0 @@
|
||||
FROM nginx:alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
@@ -1,16 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index all-steps.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
}
|
||||
|
||||
location ~ \.html$ {
|
||||
default_type text/html;
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { StepId } from '../../domain/value-objects/StepId';
|
||||
import {
|
||||
NavigationResult,
|
||||
FormFillResult,
|
||||
ClickResult,
|
||||
WaitResult,
|
||||
ModalResult,
|
||||
AutomationResult,
|
||||
} from './AutomationResults';
|
||||
|
||||
export interface IBrowserAutomation {
|
||||
navigateToPage(url: string): Promise<NavigationResult>;
|
||||
fillFormField(fieldName: string, value: string): Promise<FormFillResult>;
|
||||
clickElement(selector: string): Promise<ClickResult>;
|
||||
waitForElement(selector: string, maxWaitMs?: number): Promise<WaitResult>;
|
||||
handleModal(stepId: StepId, action: string): Promise<ModalResult>;
|
||||
|
||||
/**
|
||||
* Execute a complete workflow step with all required browser operations.
|
||||
* Uses IRacingSelectorMap to locate elements and performs appropriate actions.
|
||||
*
|
||||
* @param stepId - The step to execute (1-18)
|
||||
* @param config - Session configuration with form field values
|
||||
* @returns AutomationResult with success/failure and metadata
|
||||
*/
|
||||
executeStep?(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult>;
|
||||
|
||||
connect?(): Promise<void>;
|
||||
disconnect?(): Promise<void>;
|
||||
isConnected?(): boolean;
|
||||
}
|
||||
@@ -92,8 +92,9 @@ export interface IScreenAutomation {
|
||||
|
||||
/**
|
||||
* Initialize the automation connection.
|
||||
* Returns an AutomationResult indicating success or failure.
|
||||
*/
|
||||
connect?(): Promise<void>;
|
||||
connect?(): Promise<AutomationResult>;
|
||||
|
||||
/**
|
||||
* Clean up resources.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AutomationSession } from '../../domain/entities/AutomationSession';
|
||||
import { HostedSessionConfig } from '../../domain/entities/HostedSessionConfig';
|
||||
import { IAutomationEngine } from '../ports/IAutomationEngine';
|
||||
import { IBrowserAutomation } from '../ports/IBrowserAutomation';
|
||||
import type { IBrowserAutomation } from '../ports/IScreenAutomation';
|
||||
import { ISessionRepository } from '../ports/ISessionRepository';
|
||||
|
||||
export interface SessionDTO {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,166 +0,0 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IAutomationEngine, ValidationResult } from '../../../packages/application/ports/IAutomationEngine';
|
||||
import { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
|
||||
import { StepId } from '../../../packages/domain/value-objects/StepId';
|
||||
import { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation';
|
||||
import { ISessionRepository } from '../../../packages/application/ports/ISessionRepository';
|
||||
import { getStepName } from './selectors/IRacingSelectorMap';
|
||||
import { IAutomationEngine, ValidationResult } from '../../../application/ports/IAutomationEngine';
|
||||
import { HostedSessionConfig } from '../../../domain/entities/HostedSessionConfig';
|
||||
import { StepId } from '../../../domain/value-objects/StepId';
|
||||
import type { IBrowserAutomation } from '../../../application/ports/IScreenAutomation';
|
||||
import { ISessionRepository } from '../../../application/ports/ISessionRepository';
|
||||
import { getStepName } from './templates/IRacingTemplateMap';
|
||||
|
||||
export class MockAutomationEngineAdapter implements IAutomationEngine {
|
||||
private automationInterval: NodeJS.Timeout | null = null;
|
||||
@@ -62,7 +62,7 @@ export class MockAutomationEngineAdapter implements IAutomationEngine {
|
||||
// Execute current step using the browser automation
|
||||
if (this.browserAutomation.executeStep) {
|
||||
// Use real workflow automation with IRacingSelectorMap
|
||||
const result = await this.browserAutomation.executeStep(currentStep, config as Record<string, unknown>);
|
||||
const result = await this.browserAutomation.executeStep(currentStep, config as unknown as Record<string, unknown>);
|
||||
if (!result.success) {
|
||||
const errorMessage = `Step ${currentStep.value} (${getStepName(currentStep.value)}) failed: ${result.error}`;
|
||||
console.error(errorMessage);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { StepId } from '../../../packages/domain/value-objects/StepId';
|
||||
import { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
|
||||
import { IBrowserAutomation } from '../../../packages/application/ports/IBrowserAutomation';
|
||||
import { StepId } from '../../../domain/value-objects/StepId';
|
||||
import { HostedSessionConfig } from '../../../domain/entities/HostedSessionConfig';
|
||||
import type { IBrowserAutomation } from '../../../application/ports/IScreenAutomation';
|
||||
import {
|
||||
NavigationResult,
|
||||
FormFillResult,
|
||||
ClickResult,
|
||||
WaitResult,
|
||||
ModalResult,
|
||||
} from '../../../packages/application/ports/AutomationResults';
|
||||
AutomationResult,
|
||||
} from '../../../application/ports/AutomationResults';
|
||||
|
||||
interface MockConfig {
|
||||
simulateFailures?: boolean;
|
||||
@@ -37,8 +38,9 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation {
|
||||
};
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
async connect(): Promise<AutomationResult> {
|
||||
this.connected = true;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
@@ -105,7 +107,7 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation {
|
||||
};
|
||||
}
|
||||
|
||||
async executeStep(stepId: StepId, config: HostedSessionConfig): Promise<StepExecutionResult> {
|
||||
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
|
||||
if (this.shouldSimulateFailure()) {
|
||||
throw new Error(`Simulated failure at step ${stepId.value}`);
|
||||
}
|
||||
@@ -130,11 +132,11 @@ export class MockBrowserAutomationAdapter implements IBrowserAutomation {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stepId: stepId.value,
|
||||
wasModalStep: stepId.isModalStep(),
|
||||
shouldStop: stepId.isFinalStep(),
|
||||
executionTime,
|
||||
metrics: {
|
||||
metadata: {
|
||||
stepId: stepId.value,
|
||||
wasModalStep: stepId.isModalStep(),
|
||||
shouldStop: stepId.isFinalStep(),
|
||||
executionTime,
|
||||
totalDelay,
|
||||
operationCount,
|
||||
},
|
||||
|
||||
@@ -94,7 +94,11 @@ export class PermissionService {
|
||||
platform,
|
||||
};
|
||||
|
||||
this.logger.debug('Permission status retrieved', this.cachedStatus);
|
||||
this.logger.debug('Permission status retrieved', {
|
||||
accessibility: this.cachedStatus.accessibility,
|
||||
screenRecording: this.cachedStatus.screenRecording,
|
||||
platform: this.cachedStatus.platform,
|
||||
});
|
||||
|
||||
return this.cachedStatus;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export class TemplateMatchingService {
|
||||
template.searchRegion.width,
|
||||
template.searchRegion.height
|
||||
);
|
||||
foundRegion = await screen.findRegion(templateImage, searchArea);
|
||||
foundRegion = await screen.find(templateImage, { searchRegion: searchArea });
|
||||
} else {
|
||||
// Search entire screen
|
||||
foundRegion = await screen.find(templateImage);
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
/**
|
||||
* Automation adapters for browser automation.
|
||||
* Automation adapters for OS-level screen automation.
|
||||
*
|
||||
* Exports:
|
||||
* - MockBrowserAutomationAdapter: Mock adapter for testing
|
||||
* - BrowserDevToolsAdapter: Real browser automation via Chrome DevTools Protocol
|
||||
* - NutJsAutomationAdapter: OS-level automation via nut.js
|
||||
* - PermissionService: macOS permission checking for automation
|
||||
* - IRacingSelectorMap: CSS selectors for iRacing UI elements
|
||||
* - ScreenRecognitionService: Image template matching for UI detection
|
||||
* - TemplateMatchingService: Low-level template matching operations
|
||||
* - WindowFocusService: Window management for automation
|
||||
* - IRacingTemplateMap: Image templates for iRacing UI elements
|
||||
*/
|
||||
|
||||
// Adapters
|
||||
export { MockBrowserAutomationAdapter } from './MockBrowserAutomationAdapter';
|
||||
export { BrowserDevToolsAdapter, DevToolsConfig } from './BrowserDevToolsAdapter';
|
||||
export { NutJsAutomationAdapter, NutJsConfig } from './NutJsAutomationAdapter';
|
||||
export { NutJsAutomationAdapter } from './NutJsAutomationAdapter';
|
||||
export type { NutJsConfig } from './NutJsAutomationAdapter';
|
||||
|
||||
// Permission service
|
||||
export { PermissionService, PermissionStatus, PermissionCheckResult } from './PermissionService';
|
||||
// Services
|
||||
export { PermissionService } from './PermissionService';
|
||||
export type { PermissionStatus, PermissionCheckResult } from './PermissionService';
|
||||
export { ScreenRecognitionService } from './ScreenRecognitionService';
|
||||
export { TemplateMatchingService } from './TemplateMatchingService';
|
||||
export { WindowFocusService } from './WindowFocusService';
|
||||
|
||||
// Fixture server
|
||||
export { FixtureServerService, IFixtureServerService } from './FixtureServerService';
|
||||
|
||||
// Selector map and utilities
|
||||
// Template map and utilities
|
||||
export {
|
||||
IRacingSelectorMap,
|
||||
IRacingSelectorMapType,
|
||||
StepSelectors,
|
||||
getStepSelectors,
|
||||
IRacingTemplateMap,
|
||||
getStepTemplates,
|
||||
getStepName,
|
||||
isModalStep,
|
||||
} from './selectors/IRacingSelectorMap';
|
||||
getLoginIndicators,
|
||||
getLogoutIndicators,
|
||||
} from './templates/IRacingTemplateMap';
|
||||
export type { IRacingTemplateMapType, StepTemplates } from './templates/IRacingTemplateMap';
|
||||
@@ -1,399 +0,0 @@
|
||||
/**
|
||||
* CSS Selector map for iRacing hosted session workflow.
|
||||
* Selectors are derived from HTML samples in resources/iracing-hosted-sessions/
|
||||
*
|
||||
* The iRacing UI uses Chakra UI/React with dynamic CSS classes.
|
||||
* We prefer stable selectors: data-testid, id, aria-labels, role attributes.
|
||||
*/
|
||||
|
||||
export interface StepSelectors {
|
||||
/** Primary container/step identifier */
|
||||
container?: string;
|
||||
/** Wizard sidebar navigation link */
|
||||
sidebarLink?: string;
|
||||
/** Wizard top navigation link */
|
||||
wizardNav?: string;
|
||||
/** Form fields for this step */
|
||||
fields?: Record<string, string>;
|
||||
/** Buttons specific to this step */
|
||||
buttons?: Record<string, string>;
|
||||
/** Modal selectors if this is a modal step */
|
||||
modal?: {
|
||||
container: string;
|
||||
closeButton: string;
|
||||
confirmButton?: string;
|
||||
searchInput?: string;
|
||||
resultsList?: string;
|
||||
selectButton?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IRacingSelectorMapType {
|
||||
/** Common selectors used across multiple steps */
|
||||
common: {
|
||||
mainModal: string;
|
||||
modalDialog: string;
|
||||
modalContent: string;
|
||||
modalTitle: string;
|
||||
modalCloseButton: string;
|
||||
checkoutButton: string;
|
||||
backButton: string;
|
||||
nextButton: string;
|
||||
wizardContainer: string;
|
||||
wizardSidebar: string;
|
||||
searchInput: string;
|
||||
loadingSpinner: string;
|
||||
};
|
||||
/** Step-specific selectors */
|
||||
steps: Record<number, StepSelectors>;
|
||||
/** iRacing-specific URLs */
|
||||
urls: {
|
||||
base: string;
|
||||
hostedRacing: string;
|
||||
login: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete selector map for iRacing hosted session creation workflow.
|
||||
*
|
||||
* Steps:
|
||||
* 1. LOGIN - Login page (handled externally)
|
||||
* 2. HOSTED_RACING - Navigate to hosted racing section
|
||||
* 3. CREATE_RACE - Click create race button
|
||||
* 4. RACE_INFORMATION - Fill session name, password, description
|
||||
* 5. SERVER_DETAILS - Select server region, launch time
|
||||
* 6. SET_ADMINS - Admin configuration (modal at step 6)
|
||||
* 7. TIME_LIMITS - Configure time limits
|
||||
* 8. SET_CARS - Car selection overview
|
||||
* 9. ADD_CAR - Add a car (modal at step 9)
|
||||
* 10. SET_CAR_CLASSES - Configure car classes
|
||||
* 11. SET_TRACK - Track selection overview
|
||||
* 12. ADD_TRACK - Add a track (modal at step 12)
|
||||
* 13. TRACK_OPTIONS - Configure track options
|
||||
* 14. TIME_OF_DAY - Configure time of day
|
||||
* 15. WEATHER - Configure weather
|
||||
* 16. RACE_OPTIONS - Configure race options
|
||||
* 17. TEAM_DRIVING - Configure team driving
|
||||
* 18. TRACK_CONDITIONS - Final review (safety checkpoint - no final submit)
|
||||
*/
|
||||
export const IRacingSelectorMap: IRacingSelectorMapType = {
|
||||
common: {
|
||||
mainModal: '#create-race-modal',
|
||||
modalDialog: '#create-race-modal-modal-dialog',
|
||||
modalContent: '#create-race-modal-modal-content',
|
||||
modalTitle: '[data-testid="modal-title"]',
|
||||
modalCloseButton: '.modal-header .close, [data-testid="button-close-modal"]',
|
||||
checkoutButton: '.btn.btn-success',
|
||||
backButton: '.btn.btn-secondary:has(.icon-caret-left)',
|
||||
nextButton: '.btn.btn-secondary:has(.icon-caret-right)',
|
||||
wizardContainer: '#create-race-wizard',
|
||||
wizardSidebar: '.wizard-sidebar',
|
||||
searchInput: '.wizard-sidebar input[type="text"][placeholder="Search"]',
|
||||
loadingSpinner: '.loader-container .loader',
|
||||
},
|
||||
urls: {
|
||||
base: 'https://members-ng.iracing.com',
|
||||
hostedRacing: 'https://members-ng.iracing.com/web/racing/hosted',
|
||||
login: 'https://members-ng.iracing.com/login',
|
||||
},
|
||||
steps: {
|
||||
// Step 1: LOGIN - External, handled before automation
|
||||
1: {
|
||||
container: '#login-form, .login-container',
|
||||
fields: {
|
||||
email: 'input[name="email"], #email',
|
||||
password: 'input[name="password"], #password',
|
||||
},
|
||||
buttons: {
|
||||
submit: 'button[type="submit"], .login-button',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 2: HOSTED_RACING - Navigate to hosted racing page
|
||||
2: {
|
||||
container: '#hosted-sessions, [data-page="hosted"]',
|
||||
sidebarLink: 'a[href*="/racing/hosted"]',
|
||||
buttons: {
|
||||
createRace: '.btn:has-text("Create a Race"), [data-action="create-race"]',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 3: CREATE_RACE - Click create race to open modal
|
||||
3: {
|
||||
container: '[data-modal-component="ModalCreateRace"]',
|
||||
buttons: {
|
||||
createRace: 'button:has-text("Create a Race"), .btn-primary:has-text("Create")',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 4: RACE_INFORMATION - Fill session name, password, description
|
||||
4: {
|
||||
container: '#set-session-information',
|
||||
sidebarLink: '#wizard-sidebar-link-set-session-information',
|
||||
wizardNav: '[data-testid="wizard-nav-set-session-information"]',
|
||||
fields: {
|
||||
sessionName: '.form-group:has(label:has-text("Session Name")) input, input[name="sessionName"]',
|
||||
password: '.form-group:has(label:has-text("Password")) input, input[name="password"]',
|
||||
description: '.form-group:has(label:has-text("Description")) textarea, textarea[name="description"]',
|
||||
},
|
||||
buttons: {
|
||||
next: '.wizard-footer .btn:has-text("Server Details")',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 5: SERVER_DETAILS - Select server region and launch time
|
||||
5: {
|
||||
container: '#set-server-details',
|
||||
sidebarLink: '#wizard-sidebar-link-set-server-details',
|
||||
wizardNav: '[data-testid="wizard-nav-set-server-details"]',
|
||||
fields: {
|
||||
serverRegion: '.chakra-accordion__button[data-index="0"]',
|
||||
launchTime: 'input[name="launchTime"], [id*="field-"]:has(+ [placeholder="Now"])',
|
||||
startNow: '.switch:has(input[value="startNow"])',
|
||||
},
|
||||
buttons: {
|
||||
next: '.wizard-footer .btn:has-text("Admins")',
|
||||
back: '.wizard-footer .btn:has-text("Race Information")',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 6: SET_ADMINS - Admin configuration (modal step)
|
||||
6: {
|
||||
container: '#set-admins',
|
||||
sidebarLink: '#wizard-sidebar-link-set-admins',
|
||||
wizardNav: '[data-testid="wizard-nav-set-admins"]',
|
||||
buttons: {
|
||||
addAdmin: '.btn:has-text("Add Admin"), .btn-primary:has(.icon-add)',
|
||||
next: '.wizard-footer .btn:has-text("Time Limit")',
|
||||
back: '.wizard-footer .btn:has-text("Server Details")',
|
||||
},
|
||||
modal: {
|
||||
container: '#add-admin-modal, .modal:has([data-modal-component="AddAdmin"])',
|
||||
closeButton: '.modal .close, [data-testid="button-close-modal"]',
|
||||
searchInput: 'input[placeholder*="Search"], input[name="adminSearch"]',
|
||||
resultsList: '.admin-list, .search-results',
|
||||
selectButton: '.btn:has-text("Select"), .btn-primary:has-text("Add")',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 7: TIME_LIMITS - Configure time limits
|
||||
7: {
|
||||
container: '#set-time-limit',
|
||||
sidebarLink: '#wizard-sidebar-link-set-time-limit',
|
||||
wizardNav: '[data-testid="wizard-nav-set-time-limit"]',
|
||||
fields: {
|
||||
practiceLength: 'input[name="practiceLength"]',
|
||||
qualifyLength: 'input[name="qualifyLength"]',
|
||||
raceLength: 'input[name="raceLength"]',
|
||||
warmupLength: 'input[name="warmupLength"]',
|
||||
},
|
||||
buttons: {
|
||||
next: '.wizard-footer .btn:has-text("Cars")',
|
||||
back: '.wizard-footer .btn:has-text("Admins")',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 8: SET_CARS - Car selection overview
|
||||
8: {
|
||||
container: '#set-cars',
|
||||
sidebarLink: '#wizard-sidebar-link-set-cars',
|
||||
wizardNav: '[data-testid="wizard-nav-set-cars"]',
|
||||
buttons: {
|
||||
addCar: '.btn:has-text("Add Car"), .btn-primary:has(.icon-add)',
|
||||
next: '.wizard-footer .btn:has-text("Track")',
|
||||
back: '.wizard-footer .btn:has-text("Time Limit")',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 9: ADD_CAR - Add a car (modal step)
|
||||
9: {
|
||||
container: '#set-cars',
|
||||
sidebarLink: '#wizard-sidebar-link-set-cars',
|
||||
wizardNav: '[data-testid="wizard-nav-set-cars"]',
|
||||
modal: {
|
||||
container: '#add-car-modal, .modal:has(.car-list)',
|
||||
closeButton: '.modal .close, [aria-label="Close"]',
|
||||
searchInput: 'input[placeholder*="Search"], .car-search input',
|
||||
resultsList: '.car-list table tbody, .car-grid',
|
||||
selectButton: '.btn:has-text("Select"), .btn-primary.btn-xs:has-text("Select")',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 10: SET_CAR_CLASSES - Configure car classes
|
||||
10: {
|
||||
container: '#set-car-classes, #set-cars',
|
||||
sidebarLink: '#wizard-sidebar-link-set-cars',
|
||||
wizardNav: '[data-testid="wizard-nav-set-cars"]',
|
||||
fields: {
|
||||
carClass: 'select[name="carClass"], .car-class-select',
|
||||
},
|
||||
buttons: {
|
||||
next: '.wizard-footer .btn:has-text("Track")',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 11: SET_TRACK - Track selection overview
|
||||
11: {
|
||||
container: '#set-track',
|
||||
sidebarLink: '#wizard-sidebar-link-set-track',
|
||||
wizardNav: '[data-testid="wizard-nav-set-track"]',
|
||||
buttons: {
|
||||
addTrack: '.btn:has-text("Add Track"), .btn-primary:has(.icon-add)',
|
||||
next: '.wizard-footer .btn:has-text("Track Options")',
|
||||
back: '.wizard-footer .btn:has-text("Cars")',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 12: ADD_TRACK - Add a track (modal step)
|
||||
12: {
|
||||
container: '#set-track',
|
||||
sidebarLink: '#wizard-sidebar-link-set-track',
|
||||
wizardNav: '[data-testid="wizard-nav-set-track"]',
|
||||
modal: {
|
||||
container: '#add-track-modal, .modal:has(.track-list)',
|
||||
closeButton: '.modal .close, [aria-label="Close"]',
|
||||
searchInput: 'input[placeholder*="Search"], .track-search input',
|
||||
resultsList: '.track-list table tbody, .track-grid',
|
||||
selectButton: '.btn:has-text("Select"), .btn-primary.btn-xs:has-text("Select")',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 13: TRACK_OPTIONS - Configure track options
|
||||
13: {
|
||||
container: '#set-track-options',
|
||||
sidebarLink: '#wizard-sidebar-link-set-track-options',
|
||||
wizardNav: '[data-testid="wizard-nav-set-track-options"]',
|
||||
fields: {
|
||||
trackConfig: 'select[name="trackConfig"]',
|
||||
pitStalls: 'input[name="pitStalls"]',
|
||||
},
|
||||
buttons: {
|
||||
next: '.wizard-footer .btn:has-text("Time of Day")',
|
||||
back: '.wizard-footer .btn:has-text("Track")',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 14: TIME_OF_DAY - Configure time of day
|
||||
14: {
|
||||
container: '#set-time-of-day',
|
||||
sidebarLink: '#wizard-sidebar-link-set-time-of-day',
|
||||
wizardNav: '[data-testid="wizard-nav-set-time-of-day"]',
|
||||
fields: {
|
||||
timeOfDay: 'input[name="timeOfDay"], .time-slider',
|
||||
date: 'input[name="date"], .date-picker',
|
||||
},
|
||||
buttons: {
|
||||
next: '.wizard-footer .btn:has-text("Weather")',
|
||||
back: '.wizard-footer .btn:has-text("Track Options")',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 15: WEATHER - Configure weather
|
||||
15: {
|
||||
container: '#set-weather',
|
||||
sidebarLink: '#wizard-sidebar-link-set-weather',
|
||||
wizardNav: '[data-testid="wizard-nav-set-weather"]',
|
||||
fields: {
|
||||
weatherType: 'select[name="weatherType"]',
|
||||
temperature: 'input[name="temperature"]',
|
||||
humidity: 'input[name="humidity"]',
|
||||
windSpeed: 'input[name="windSpeed"]',
|
||||
windDirection: 'input[name="windDirection"]',
|
||||
},
|
||||
buttons: {
|
||||
next: '.wizard-footer .btn:has-text("Race Options")',
|
||||
back: '.wizard-footer .btn:has-text("Time of Day")',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 16: RACE_OPTIONS - Configure race options
|
||||
16: {
|
||||
container: '#set-race-options',
|
||||
sidebarLink: '#wizard-sidebar-link-set-race-options',
|
||||
wizardNav: '[data-testid="wizard-nav-set-race-options"]',
|
||||
fields: {
|
||||
maxDrivers: 'input[name="maxDrivers"]',
|
||||
hardcoreIncidents: '.switch:has(input[name="hardcoreIncidents"])',
|
||||
rollingStarts: '.switch:has(input[name="rollingStarts"])',
|
||||
fullCourseCautions: '.switch:has(input[name="fullCourseCautions"])',
|
||||
},
|
||||
buttons: {
|
||||
next: '.wizard-footer .btn:has-text("Track Conditions")',
|
||||
back: '.wizard-footer .btn:has-text("Weather")',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 17: TEAM_DRIVING - Configure team driving (if applicable)
|
||||
17: {
|
||||
container: '#set-team-driving',
|
||||
fields: {
|
||||
teamDriving: '.switch:has(input[name="teamDriving"])',
|
||||
minDrivers: 'input[name="minDrivers"]',
|
||||
maxDrivers: 'input[name="maxDrivers"]',
|
||||
},
|
||||
buttons: {
|
||||
next: '.wizard-footer .btn:has-text("Track Conditions")',
|
||||
back: '.wizard-footer .btn:has-text("Race Options")',
|
||||
},
|
||||
},
|
||||
|
||||
// Step 18: TRACK_CONDITIONS - Final review (safety checkpoint - NO final submit)
|
||||
18: {
|
||||
container: '#set-track-conditions',
|
||||
sidebarLink: '#wizard-sidebar-link-set-track-conditions',
|
||||
wizardNav: '[data-testid="wizard-nav-set-track-conditions"]',
|
||||
fields: {
|
||||
trackState: 'select[name="trackState"]',
|
||||
marbles: '.switch:has(input[name="marbles"])',
|
||||
rubberedTrack: '.switch:has(input[name="rubberedTrack"])',
|
||||
},
|
||||
buttons: {
|
||||
// NOTE: Checkout button is intentionally NOT included for safety
|
||||
// The automation should stop here and let the user review/confirm manually
|
||||
back: '.wizard-footer .btn:has-text("Race Options")',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get selectors for a specific step
|
||||
*/
|
||||
export function getStepSelectors(stepId: number): StepSelectors | undefined {
|
||||
return IRacingSelectorMap.steps[stepId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a step is a modal step (requires opening a secondary dialog)
|
||||
*/
|
||||
export function isModalStep(stepId: number): boolean {
|
||||
return stepId === 6 || stepId === 9 || stepId === 12;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the step name for logging/debugging
|
||||
*/
|
||||
export function getStepName(stepId: number): string {
|
||||
const stepNames: Record<number, string> = {
|
||||
1: 'LOGIN',
|
||||
2: 'HOSTED_RACING',
|
||||
3: 'CREATE_RACE',
|
||||
4: 'RACE_INFORMATION',
|
||||
5: 'SERVER_DETAILS',
|
||||
6: 'SET_ADMINS',
|
||||
7: 'TIME_LIMITS',
|
||||
8: 'SET_CARS',
|
||||
9: 'ADD_CAR',
|
||||
10: 'SET_CAR_CLASSES',
|
||||
11: 'SET_TRACK',
|
||||
12: 'ADD_TRACK',
|
||||
13: 'TRACK_OPTIONS',
|
||||
14: 'TIME_OF_DAY',
|
||||
15: 'WEATHER',
|
||||
16: 'RACE_OPTIONS',
|
||||
17: 'TEAM_DRIVING',
|
||||
18: 'TRACK_CONDITIONS',
|
||||
};
|
||||
return stepNames[stepId] || `UNKNOWN_STEP_${stepId}`;
|
||||
}
|
||||
@@ -5,33 +5,21 @@
|
||||
* 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
|
||||
* - NODE_ENV=development → MockBrowserAutomation → N/A → N/A
|
||||
*/
|
||||
|
||||
export type AutomationMode = 'development' | 'production' | 'test';
|
||||
export type AutomationMode = '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;
|
||||
|
||||
/** Development mode configuration (Browser DevTools with fixture server) */
|
||||
devTools?: {
|
||||
browserWSEndpoint?: string;
|
||||
debuggingPort?: number;
|
||||
};
|
||||
|
||||
/** Production mode configuration (nut.js) */
|
||||
nutJs?: {
|
||||
mouseSpeed?: number;
|
||||
@@ -41,9 +29,6 @@ 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 */
|
||||
@@ -57,8 +42,7 @@ export interface AutomationEnvironmentConfig {
|
||||
*
|
||||
* Mapping:
|
||||
* - NODE_ENV=production → 'production'
|
||||
* - NODE_ENV=test → 'test'
|
||||
* - NODE_ENV=development → 'development' (default)
|
||||
* - All other values → 'test' (default)
|
||||
*
|
||||
* For backward compatibility, if AUTOMATION_MODE is explicitly set,
|
||||
* it will be used with a deprecation warning logged to console.
|
||||
@@ -70,34 +54,28 @@ export function getAutomationMode(): AutomationMode {
|
||||
if (legacyMode && isValidLegacyAutomationMode(legacyMode)) {
|
||||
console.warn(
|
||||
`[DEPRECATED] AUTOMATION_MODE environment variable is deprecated. ` +
|
||||
`Use NODE_ENV instead. Mapping: dev→development, mock→test, production→production`
|
||||
`Use NODE_ENV instead. Mapping: dev→test, 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';
|
||||
return 'test';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load automation configuration from environment variables.
|
||||
*
|
||||
* Environment variables:
|
||||
* - NODE_ENV: 'development' | 'production' | 'test' (default: 'development')
|
||||
* - NODE_ENV: 'production' | 'test' (default: 'test')
|
||||
* - 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')
|
||||
* - TEMPLATE_PATH: Path to template images (default: './resources/templates')
|
||||
* - OCR_CONFIDENCE: OCR confidence threshold (default: 0.9)
|
||||
* - 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
|
||||
*/
|
||||
@@ -106,10 +84,6 @@ export function loadAutomationConfig(): AutomationEnvironmentConfig {
|
||||
|
||||
return {
|
||||
mode,
|
||||
devTools: {
|
||||
debuggingPort: parseIntSafe(process.env.CHROME_DEBUG_PORT, 9222),
|
||||
browserWSEndpoint: process.env.CHROME_WS_ENDPOINT,
|
||||
},
|
||||
nutJs: {
|
||||
mouseSpeed: parseIntSafe(process.env.NUTJS_MOUSE_SPEED, 1000),
|
||||
keyboardDelay: parseIntSafe(process.env.NUTJS_KEYBOARD_DELAY, 50),
|
||||
@@ -117,11 +91,6 @@ 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',
|
||||
@@ -132,7 +101,7 @@ 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';
|
||||
return value === 'production' || value === 'test';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,7 +116,7 @@ function isValidLegacyAutomationMode(value: string | undefined): value is Legacy
|
||||
*/
|
||||
function mapLegacyMode(legacy: LegacyAutomationMode): AutomationMode {
|
||||
switch (legacy) {
|
||||
case 'dev': return 'development';
|
||||
case 'dev': return 'test';
|
||||
case 'mock': return 'test';
|
||||
case 'production': return 'production';
|
||||
}
|
||||
|
||||
@@ -2,10 +2,5 @@
|
||||
* Configuration module exports for infrastructure layer.
|
||||
*/
|
||||
|
||||
export {
|
||||
AutomationMode,
|
||||
AutomationEnvironmentConfig,
|
||||
FixtureServerConfig,
|
||||
loadAutomationConfig,
|
||||
getAutomationMode,
|
||||
} from './AutomationConfig';
|
||||
export type { AutomationMode, AutomationEnvironmentConfig } from './AutomationConfig';
|
||||
export { loadAutomationConfig, getAutomationMode } from './AutomationConfig';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AutomationSession } from '../../packages/domain/entities/AutomationSession';
|
||||
import { SessionStateValue } from '../../packages/domain/value-objects/SessionState';
|
||||
import { ISessionRepository } from '../../packages/application/ports/ISessionRepository';
|
||||
import { AutomationSession } from '../../domain/entities/AutomationSession';
|
||||
import { SessionStateValue } from '../../domain/value-objects/SessionState';
|
||||
import { ISessionRepository } from '../../application/ports/ISessionRepository';
|
||||
|
||||
export class InMemorySessionRepository implements ISessionRepository {
|
||||
private sessions: Map<string, AutomationSession> = new Map();
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,422 +0,0 @@
|
||||
/**
|
||||
* E2E Tests for Automation Workflow
|
||||
*
|
||||
* IMPORTANT: These tests run in HEADLESS mode only.
|
||||
* No headed browser tests are allowed per project requirements.
|
||||
*
|
||||
* Tests verify that the IRacingSelectorMap selectors correctly find
|
||||
* elements in the static HTML fixture files.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
||||
import puppeteer, { Browser, Page } from 'puppeteer';
|
||||
import { createServer, Server } from 'http';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
IRacingSelectorMap,
|
||||
getStepSelectors,
|
||||
getStepName,
|
||||
isModalStep,
|
||||
} from '../../packages/infrastructure/adapters/automation/selectors/IRacingSelectorMap';
|
||||
|
||||
// ==================== HEADLESS ENFORCEMENT ====================
|
||||
const HEADLESS_MODE = true; // NEVER change this to false - headless only!
|
||||
|
||||
// ==================== Test Configuration ====================
|
||||
const FIXTURES_DIR = join(process.cwd(), 'resources', 'iracing-hosted-sessions');
|
||||
const TEST_PORT = 3456;
|
||||
const TEST_BASE_URL = `http://localhost:${TEST_PORT}`;
|
||||
|
||||
// Map step numbers to fixture filenames
|
||||
const STEP_TO_FIXTURE: Record<number, string> = {
|
||||
2: '01-hosted-racing.html',
|
||||
3: '02-create-a-race.html',
|
||||
4: '03-race-information.html',
|
||||
5: '04-server-details.html',
|
||||
6: '05-set-admins.html',
|
||||
7: '07-time-limits.html',
|
||||
8: '08-set-cars.html',
|
||||
9: '09-add-a-car.html',
|
||||
10: '10-set-car-classes.html',
|
||||
11: '11-set-track.html',
|
||||
12: '12-add-a-track.html',
|
||||
13: '13-track-options.html',
|
||||
14: '14-time-of-day.html',
|
||||
15: '15-weather.html',
|
||||
16: '16-race-options.html',
|
||||
17: '17-team-driving.html',
|
||||
18: '18-track-conditions.html',
|
||||
};
|
||||
|
||||
// ==================== HTTP Server for Fixtures ====================
|
||||
function createFixtureServer(): Server {
|
||||
return createServer((req, res) => {
|
||||
const url = req.url || '/';
|
||||
const filename = url.slice(1) || 'index.html';
|
||||
const filepath = join(FIXTURES_DIR, filename);
|
||||
|
||||
if (existsSync(filepath)) {
|
||||
const content = readFileSync(filepath, 'utf-8');
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(content);
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Test Suite ====================
|
||||
describe('E2E: Automation Workflow - HEADLESS MODE', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
let server: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Start fixture server
|
||||
server = createFixtureServer();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(TEST_PORT, () => {
|
||||
console.log(`Fixture server running on port ${TEST_PORT}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Launch browser in HEADLESS mode - this is enforced and cannot be changed
|
||||
browser = await puppeteer.launch({
|
||||
headless: HEADLESS_MODE, // MUST be true - never run headed
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
],
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
// Close browser
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
// Stop fixture server
|
||||
if (server) {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
page = await browser.newPage();
|
||||
// Set viewport for consistent rendering
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (page) {
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Headless Mode Verification ====================
|
||||
describe('Headless Mode Verification', () => {
|
||||
it('should be running in headless mode', async () => {
|
||||
// This test verifies we're in headless mode
|
||||
expect(HEADLESS_MODE).toBe(true);
|
||||
|
||||
// Additional check - headless browsers have specific user agent patterns
|
||||
const userAgent = await page.evaluate(() => navigator.userAgent);
|
||||
// Headless Chrome includes "HeadlessChrome" in newer versions
|
||||
// or we can verify by checking we can't access certain headed-only features
|
||||
expect(browser.connected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== IRacingSelectorMap Tests ====================
|
||||
describe('IRacingSelectorMap Structure', () => {
|
||||
it('should have all 18 steps defined', () => {
|
||||
for (let step = 1; step <= 18; step++) {
|
||||
const selectors = getStepSelectors(step);
|
||||
expect(selectors, `Step ${step} should have selectors`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should identify modal steps correctly', () => {
|
||||
expect(isModalStep(6)).toBe(true); // SET_ADMINS
|
||||
expect(isModalStep(9)).toBe(true); // ADD_CAR
|
||||
expect(isModalStep(12)).toBe(true); // ADD_TRACK
|
||||
expect(isModalStep(1)).toBe(false);
|
||||
expect(isModalStep(18)).toBe(false);
|
||||
});
|
||||
|
||||
it('should have correct step names', () => {
|
||||
expect(getStepName(1)).toBe('LOGIN');
|
||||
expect(getStepName(4)).toBe('RACE_INFORMATION');
|
||||
expect(getStepName(9)).toBe('ADD_CAR');
|
||||
expect(getStepName(18)).toBe('TRACK_CONDITIONS');
|
||||
});
|
||||
|
||||
it('should NOT have checkout button in final step (safety)', () => {
|
||||
const step18 = getStepSelectors(18);
|
||||
expect(step18?.buttons?.checkout).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should have common selectors defined', () => {
|
||||
expect(IRacingSelectorMap.common.mainModal).toBeDefined();
|
||||
expect(IRacingSelectorMap.common.checkoutButton).toBeDefined();
|
||||
expect(IRacingSelectorMap.common.wizardContainer).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Fixture Loading Tests ====================
|
||||
describe('HTML Fixture Loading', () => {
|
||||
it('should load hosted racing fixture (step 2)', async () => {
|
||||
const fixture = STEP_TO_FIXTURE[2];
|
||||
if (!fixture) {
|
||||
console.log('Skipping: No fixture for step 2');
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto(`${TEST_BASE_URL}/${fixture}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
const title = await page.title();
|
||||
expect(title).toBeDefined();
|
||||
}, 60000);
|
||||
|
||||
it('should load race information fixture (step 4)', async () => {
|
||||
const fixture = STEP_TO_FIXTURE[4];
|
||||
if (!fixture) {
|
||||
console.log('Skipping: No fixture for step 4');
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto(`${TEST_BASE_URL}/${fixture}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
const content = await page.content();
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
// ==================== Selector Validation Tests ====================
|
||||
describe('Selector Validation Against Fixtures', () => {
|
||||
// Test common selectors that should exist in the wizard pages
|
||||
const wizardSteps = [4, 5, 6, 7, 8, 10, 11, 13, 14, 15, 16, 17, 18];
|
||||
|
||||
for (const stepNum of wizardSteps) {
|
||||
const fixture = STEP_TO_FIXTURE[stepNum];
|
||||
if (!fixture) continue;
|
||||
|
||||
it(`should find wizard container in step ${stepNum} (${getStepName(stepNum)})`, async () => {
|
||||
await page.goto(`${TEST_BASE_URL}/${fixture}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
// Check for wizard-related elements using broad selectors
|
||||
// The actual iRacing selectors may use dynamic classes, so we test for presence of expected patterns
|
||||
const hasContent = await page.evaluate(() => {
|
||||
return document.body.innerHTML.length > 0;
|
||||
});
|
||||
|
||||
expect(hasContent).toBe(true);
|
||||
}, 120000);
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Modal Step Tests ====================
|
||||
describe('Modal Step Fixtures', () => {
|
||||
it('should load add-an-admin fixture (step 6 modal)', async () => {
|
||||
const fixture = '06-add-an-admin.html';
|
||||
const filepath = join(FIXTURES_DIR, fixture);
|
||||
|
||||
if (!existsSync(filepath)) {
|
||||
console.log('Skipping: Modal fixture not available');
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto(`${TEST_BASE_URL}/${fixture}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
// Verify page loaded
|
||||
const content = await page.content();
|
||||
expect(content.length).toBeGreaterThan(1000);
|
||||
}, 120000);
|
||||
|
||||
it('should load add-a-car fixture (step 9 modal)', async () => {
|
||||
const fixture = STEP_TO_FIXTURE[9];
|
||||
if (!fixture) {
|
||||
console.log('Skipping: No fixture for step 9');
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto(`${TEST_BASE_URL}/${fixture}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
const content = await page.content();
|
||||
expect(content.length).toBeGreaterThan(1000);
|
||||
}, 120000);
|
||||
|
||||
it('should load add-a-track fixture (step 12 modal)', async () => {
|
||||
const fixture = STEP_TO_FIXTURE[12];
|
||||
if (!fixture) {
|
||||
console.log('Skipping: No fixture for step 12');
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto(`${TEST_BASE_URL}/${fixture}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
const content = await page.content();
|
||||
expect(content.length).toBeGreaterThan(1000);
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
// ==================== Workflow Progression Tests ====================
|
||||
describe('Workflow Step Progression', () => {
|
||||
it('should have fixtures for complete 18-step workflow', () => {
|
||||
// Verify we have fixtures for the workflow steps
|
||||
const availableSteps = Object.keys(STEP_TO_FIXTURE).map(Number);
|
||||
|
||||
// We should have fixtures for steps 2-18 (step 1 is login, handled externally)
|
||||
expect(availableSteps.length).toBeGreaterThanOrEqual(15);
|
||||
|
||||
// Check critical steps exist
|
||||
expect(STEP_TO_FIXTURE[4]).toBeDefined(); // RACE_INFORMATION
|
||||
expect(STEP_TO_FIXTURE[8]).toBeDefined(); // SET_CARS
|
||||
expect(STEP_TO_FIXTURE[11]).toBeDefined(); // SET_TRACK
|
||||
expect(STEP_TO_FIXTURE[18]).toBeDefined(); // TRACK_CONDITIONS
|
||||
});
|
||||
|
||||
it('should have step 18 as final step (safety checkpoint)', () => {
|
||||
const step18Selectors = getStepSelectors(18);
|
||||
|
||||
expect(step18Selectors).toBeDefined();
|
||||
expect(getStepName(18)).toBe('TRACK_CONDITIONS');
|
||||
|
||||
// Verify checkout is NOT automated (safety)
|
||||
expect(step18Selectors?.buttons?.checkout).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Element Detection Tests ====================
|
||||
describe('Element Detection in Fixtures', () => {
|
||||
it('should detect form elements in race information page', async () => {
|
||||
const fixture = STEP_TO_FIXTURE[4];
|
||||
if (!fixture) {
|
||||
console.log('Skipping: No fixture');
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto(`${TEST_BASE_URL}/${fixture}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
// Check for input elements (the page should have form inputs)
|
||||
const hasInputs = await page.evaluate(() => {
|
||||
const inputs = document.querySelectorAll('input');
|
||||
return inputs.length > 0;
|
||||
});
|
||||
|
||||
expect(hasInputs).toBe(true);
|
||||
}, 120000);
|
||||
|
||||
it('should detect buttons in fixture pages', async () => {
|
||||
const fixture = STEP_TO_FIXTURE[5];
|
||||
if (!fixture) {
|
||||
console.log('Skipping: No fixture');
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto(`${TEST_BASE_URL}/${fixture}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
// Check for button elements
|
||||
const hasButtons = await page.evaluate(() => {
|
||||
const buttons = document.querySelectorAll('button, .btn, [role="button"]');
|
||||
return buttons.length > 0;
|
||||
});
|
||||
|
||||
expect(hasButtons).toBe(true);
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
// ==================== CSS Selector Syntax Tests ====================
|
||||
describe('CSS Selector Syntax Validation', () => {
|
||||
it('should have valid CSS selector syntax for common selectors', async () => {
|
||||
// Test that selectors are syntactically valid by attempting to parse them in the browser
|
||||
const selectorsToValidate = [
|
||||
IRacingSelectorMap.common.mainModal,
|
||||
IRacingSelectorMap.common.checkoutButton,
|
||||
IRacingSelectorMap.common.wizardContainer,
|
||||
];
|
||||
|
||||
// Load a minimal page to test selector syntax
|
||||
await page.setContent('<html><body><div id="test"></div></body></html>');
|
||||
|
||||
for (const selector of selectorsToValidate) {
|
||||
// Verify selector is a valid string
|
||||
expect(typeof selector).toBe('string');
|
||||
expect(selector.length).toBeGreaterThan(0);
|
||||
|
||||
// Test selector syntax in the browser context (won't throw for valid CSS)
|
||||
const isValid = await page.evaluate((sel) => {
|
||||
try {
|
||||
document.querySelector(sel);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, selector);
|
||||
|
||||
expect(isValid, `Selector "${selector}" should be valid CSS`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have valid CSS selectors for each step', () => {
|
||||
for (let step = 1; step <= 18; step++) {
|
||||
const selectors = getStepSelectors(step);
|
||||
if (selectors?.container) {
|
||||
// Verify the selector string exists and is non-empty
|
||||
expect(typeof selectors.container).toBe('string');
|
||||
expect(selectors.container.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Standalone Headless Verification ====================
|
||||
describe('E2E: Headless Mode Enforcement', () => {
|
||||
it('HEADLESS_MODE constant must be true', () => {
|
||||
// This test will fail if anyone changes HEADLESS_MODE to false
|
||||
expect(HEADLESS_MODE).toBe(true);
|
||||
});
|
||||
|
||||
it('should never allow headed mode configuration', () => {
|
||||
// Double-check that we're enforcing headless
|
||||
const isHeadless = HEADLESS_MODE === true;
|
||||
expect(isHeadless).toBe(true);
|
||||
|
||||
if (!isHeadless) {
|
||||
throw new Error('CRITICAL: Headed mode is forbidden! Set HEADLESS_MODE=true');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,390 +0,0 @@
|
||||
/**
|
||||
* Docker-Based E2E Tests for BrowserDevToolsAdapter
|
||||
*
|
||||
* These tests run against real Docker containers:
|
||||
* - browserless/chrome: Headless Chrome with CDP exposed
|
||||
* - fixture-server: nginx serving static HTML fixtures
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Run `npm run docker:e2e:up` to start containers
|
||||
* - Chrome available at ws://localhost:9222
|
||||
* - Fixtures available at http://localhost:3456
|
||||
*
|
||||
* IMPORTANT: These tests use REAL adapters, no mocks.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { BrowserDevToolsAdapter } from '../../../packages/infrastructure/adapters/automation/BrowserDevToolsAdapter';
|
||||
import { StepId } from '../../../packages/domain/value-objects/StepId';
|
||||
import {
|
||||
IRacingSelectorMap,
|
||||
getStepSelectors,
|
||||
getStepName,
|
||||
} from '../../../packages/infrastructure/adapters/automation/selectors/IRacingSelectorMap';
|
||||
|
||||
// Environment configuration
|
||||
const CHROME_WS_ENDPOINT = process.env.CHROME_WS_ENDPOINT || 'ws://localhost:9222';
|
||||
const FIXTURE_BASE_URL = process.env.FIXTURE_BASE_URL || 'http://localhost:3456';
|
||||
const DEFAULT_TIMEOUT = 30000;
|
||||
|
||||
// Map step numbers to fixture filenames
|
||||
const STEP_TO_FIXTURE: Record<number, string> = {
|
||||
2: '01-hosted-racing.html',
|
||||
3: '02-create-a-race.html',
|
||||
4: '03-race-information.html',
|
||||
5: '04-server-details.html',
|
||||
6: '05-set-admins.html',
|
||||
7: '07-time-limits.html',
|
||||
8: '08-set-cars.html',
|
||||
9: '09-add-a-car.html',
|
||||
10: '10-set-car-classes.html',
|
||||
11: '11-set-track.html',
|
||||
12: '12-add-a-track.html',
|
||||
13: '13-track-options.html',
|
||||
14: '14-time-of-day.html',
|
||||
15: '15-weather.html',
|
||||
16: '16-race-options.html',
|
||||
17: '17-team-driving.html',
|
||||
18: '18-track-conditions.html',
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to check if Docker environment is available
|
||||
*/
|
||||
async function isDockerEnvironmentReady(): Promise<boolean> {
|
||||
try {
|
||||
// Check if Chrome CDP is accessible
|
||||
const chromeResponse = await fetch('http://localhost:9222/json/version');
|
||||
if (!chromeResponse.ok) return false;
|
||||
|
||||
// Check if fixture server is accessible
|
||||
const fixtureResponse = await fetch(`${FIXTURE_BASE_URL}/01-hosted-racing.html`);
|
||||
if (!fixtureResponse.ok) return false;
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('E2E: BrowserDevToolsAdapter - Docker Environment', () => {
|
||||
let adapter: BrowserDevToolsAdapter;
|
||||
let dockerReady: boolean;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Check if Docker environment is available
|
||||
dockerReady = await isDockerEnvironmentReady();
|
||||
|
||||
if (!dockerReady) {
|
||||
console.warn(
|
||||
'\n⚠️ Docker E2E environment not ready.\n' +
|
||||
' Run: npm run docker:e2e:up\n' +
|
||||
' Skipping Docker E2E tests.\n'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create adapter with CDP connection to Docker Chrome
|
||||
adapter = new BrowserDevToolsAdapter({
|
||||
browserWSEndpoint: CHROME_WS_ENDPOINT,
|
||||
defaultTimeout: DEFAULT_TIMEOUT,
|
||||
typingDelay: 10, // Fast typing for tests
|
||||
waitForNetworkIdle: false, // Static fixtures don't need network idle
|
||||
});
|
||||
|
||||
await adapter.connect();
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (adapter?.isConnected()) {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Connection Tests ====================
|
||||
describe('Browser Connection', () => {
|
||||
it('should connect to Docker Chrome via CDP', () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('should have a valid page after connection', () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
const url = adapter.getCurrentUrl();
|
||||
expect(url).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Navigation Tests ====================
|
||||
describe('Navigation to Fixtures', () => {
|
||||
it('should navigate to hosted racing page (step 2 fixture)', async () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
const fixtureUrl = `${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[2]}`;
|
||||
const result = await adapter.navigateToPage(fixtureUrl);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.url).toBe(fixtureUrl);
|
||||
expect(result.loadTime).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should navigate to race information page (step 4 fixture)', async () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
const fixtureUrl = `${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`;
|
||||
const result = await adapter.navigateToPage(fixtureUrl);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.url).toBe(fixtureUrl);
|
||||
});
|
||||
|
||||
it('should return error for non-existent page', async () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
const result = await adapter.navigateToPage(`${FIXTURE_BASE_URL}/nonexistent.html`);
|
||||
// Navigation may succeed but page returns 404
|
||||
expect(result.success).toBe(true); // HTTP navigation succeeds
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Element Detection Tests ====================
|
||||
describe('Element Detection in Fixtures', () => {
|
||||
it('should detect elements exist on hosted racing page', async () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[2]}`);
|
||||
|
||||
// Wait for any element to verify page loaded
|
||||
const result = await adapter.waitForElement('body', 5000);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.found).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect form inputs on race information page', async () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
|
||||
|
||||
// Check for input elements
|
||||
const bodyResult = await adapter.waitForElement('body', 5000);
|
||||
expect(bodyResult.success).toBe(true);
|
||||
|
||||
// Evaluate page content to verify inputs exist
|
||||
const hasInputs = await adapter.evaluate(() => {
|
||||
return document.querySelectorAll('input').length > 0;
|
||||
});
|
||||
expect(hasInputs).toBe(true);
|
||||
});
|
||||
|
||||
it('should return not found for non-existent element', async () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
|
||||
|
||||
const result = await adapter.waitForElement('#completely-nonexistent-element-xyz', 1000);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Click Operation Tests ====================
|
||||
describe('Click Operations', () => {
|
||||
it('should click on visible elements', async () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
|
||||
|
||||
// Try to click any button if available
|
||||
const hasButtons = await adapter.evaluate(() => {
|
||||
return document.querySelectorAll('button').length > 0;
|
||||
});
|
||||
|
||||
if (hasButtons) {
|
||||
const result = await adapter.clickElement('button');
|
||||
// May fail if button not visible/clickable, but should not throw
|
||||
expect(result).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when clicking non-existent element', async () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
|
||||
|
||||
const result = await adapter.clickElement('#nonexistent-button');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Form Fill Tests ====================
|
||||
describe('Form Field Operations', () => {
|
||||
it('should fill form field when input exists', async () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
|
||||
|
||||
// Find first input on page
|
||||
const hasInput = await adapter.evaluate(() => {
|
||||
const input = document.querySelector('input[type="text"], input:not([type])');
|
||||
return input !== null;
|
||||
});
|
||||
|
||||
if (hasInput) {
|
||||
const result = await adapter.fillFormField('input[type="text"], input:not([type])', 'Test Value');
|
||||
// May succeed or fail depending on input visibility
|
||||
expect(result).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when filling non-existent field', async () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
|
||||
|
||||
const result = await adapter.fillFormField('#nonexistent-input', 'Test Value');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Step Execution Tests ====================
|
||||
describe('Step Execution', () => {
|
||||
it('should execute step 1 (LOGIN) as skipped', async () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
const stepId = StepId.create(1);
|
||||
if (stepId.isFailure()) throw new Error('Invalid step ID');
|
||||
|
||||
const result = await adapter.executeStep(stepId.value, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.metadata?.skipped).toBe(true);
|
||||
expect(result.metadata?.reason).toBe('User pre-authenticated');
|
||||
});
|
||||
|
||||
it('should execute step 18 (TRACK_CONDITIONS) with safety stop', async () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[18]}`);
|
||||
|
||||
const stepId = StepId.create(18);
|
||||
if (stepId.isFailure()) throw new Error('Invalid step ID');
|
||||
|
||||
const result = await adapter.executeStep(stepId.value, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.metadata?.safetyStop).toBe(true);
|
||||
expect(result.metadata?.step).toBe('TRACK_CONDITIONS');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Selector Validation Tests ====================
|
||||
describe('IRacingSelectorMap Validation', () => {
|
||||
it('should have valid selectors that can be parsed by browser', async () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
|
||||
|
||||
// Test common selectors for CSS validity
|
||||
const selectorsToTest = [
|
||||
IRacingSelectorMap.common.mainModal,
|
||||
IRacingSelectorMap.common.wizardContainer,
|
||||
IRacingSelectorMap.common.nextButton,
|
||||
];
|
||||
|
||||
for (const selector of selectorsToTest) {
|
||||
const isValid = await adapter.evaluate((sel) => {
|
||||
try {
|
||||
document.querySelector(sel);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, selector as unknown as () => boolean);
|
||||
|
||||
// We're testing selector syntax, not element presence
|
||||
expect(typeof selector).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('should have selectors defined for all 18 steps', () => {
|
||||
for (let step = 1; step <= 18; step++) {
|
||||
const selectors = getStepSelectors(step);
|
||||
expect(selectors).toBeDefined();
|
||||
expect(getStepName(step)).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Page Content Tests ====================
|
||||
describe('Page Content Retrieval', () => {
|
||||
it('should get page content from fixtures', async () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
|
||||
|
||||
const content = await adapter.getPageContent();
|
||||
|
||||
expect(content).toBeDefined();
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
expect(content).toContain('<!DOCTYPE html>');
|
||||
});
|
||||
|
||||
it('should evaluate JavaScript in page context', async () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${STEP_TO_FIXTURE[4]}`);
|
||||
|
||||
const result = await adapter.evaluate(() => {
|
||||
return {
|
||||
title: document.title,
|
||||
hasBody: document.body !== null,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.hasBody).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Workflow Progression Tests ====================
|
||||
describe('Workflow Navigation', () => {
|
||||
it('should navigate through multiple fixtures sequentially', async () => {
|
||||
if (!dockerReady) return;
|
||||
|
||||
// Navigate through first few steps
|
||||
const steps = [2, 3, 4, 5];
|
||||
|
||||
for (const step of steps) {
|
||||
const fixture = STEP_TO_FIXTURE[step];
|
||||
if (!fixture) continue;
|
||||
|
||||
const result = await adapter.navigateToPage(`${FIXTURE_BASE_URL}/${fixture}`);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify page loaded
|
||||
const bodyExists = await adapter.waitForElement('body', 5000);
|
||||
expect(bodyExists.found).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Standalone Skip Test ====================
|
||||
describe('E2E Docker Environment Check', () => {
|
||||
it('should report Docker environment status', async () => {
|
||||
const ready = await isDockerEnvironmentReady();
|
||||
|
||||
if (ready) {
|
||||
console.log('✅ Docker E2E environment is ready');
|
||||
} else {
|
||||
console.log('⚠️ Docker E2E environment not available');
|
||||
console.log(' Start with: npm run docker:e2e:up');
|
||||
}
|
||||
|
||||
// This test always passes - it's informational
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as path from 'path';
|
||||
import { resolveFixturesPath } from '@/apps/companion/main/di-container';
|
||||
|
||||
describe('DIContainer path resolution', () => {
|
||||
describe('resolveFixturesPath', () => {
|
||||
describe('built mode (with /dist/ in path)', () => {
|
||||
it('should resolve fixtures path to monorepo root when dirname is apps/companion/dist/main', () => {
|
||||
// Given: The dirname as it would be in built Electron runtime (apps/companion/dist/main)
|
||||
const mockDirname = '/Users/test/Projects/gridpilot/apps/companion/dist/main';
|
||||
const relativePath = './resources/iracing-hosted-sessions';
|
||||
|
||||
// When: Resolving the fixtures path
|
||||
const resolved = resolveFixturesPath(relativePath, mockDirname);
|
||||
|
||||
// Then: Should resolve to monorepo root (4 levels up from apps/companion/dist/main)
|
||||
// Level 0: apps/companion/dist/main (__dirname)
|
||||
// Level 1: apps/companion/dist (../)
|
||||
// Level 2: apps/companion (../../)
|
||||
// Level 3: apps (../../../)
|
||||
// Level 4: gridpilot (monorepo root) (../../../../) ← CORRECT
|
||||
const expectedPath = '/Users/test/Projects/gridpilot/resources/iracing-hosted-sessions';
|
||||
expect(resolved).toBe(expectedPath);
|
||||
});
|
||||
|
||||
it('should navigate exactly 4 levels up in built mode', () => {
|
||||
// Given: A path with /dist/ that demonstrates the 4-level navigation
|
||||
const mockDirname = '/level4/level3/dist/level1';
|
||||
const relativePath = './target';
|
||||
|
||||
// When: Resolving the fixtures path
|
||||
const resolved = resolveFixturesPath(relativePath, mockDirname);
|
||||
|
||||
// Then: Should resolve to the path 4 levels up (root /)
|
||||
expect(resolved).toBe('/target');
|
||||
});
|
||||
|
||||
it('should work with different relative path formats in built mode', () => {
|
||||
// Given: Various relative path formats
|
||||
const mockDirname = '/Users/test/Projects/gridpilot/apps/companion/dist/main';
|
||||
|
||||
// When/Then: Different relative formats should all work
|
||||
expect(resolveFixturesPath('resources/fixtures', mockDirname))
|
||||
.toBe('/Users/test/Projects/gridpilot/resources/fixtures');
|
||||
|
||||
expect(resolveFixturesPath('./resources/fixtures', mockDirname))
|
||||
.toBe('/Users/test/Projects/gridpilot/resources/fixtures');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev mode (without /dist/ in path)', () => {
|
||||
it('should resolve fixtures path to monorepo root when dirname is apps/companion/main', () => {
|
||||
// Given: The dirname as it would be in dev mode (apps/companion/main)
|
||||
const mockDirname = '/Users/test/Projects/gridpilot/apps/companion/main';
|
||||
const relativePath = './resources/iracing-hosted-sessions';
|
||||
|
||||
// When: Resolving the fixtures path
|
||||
const resolved = resolveFixturesPath(relativePath, mockDirname);
|
||||
|
||||
// Then: Should resolve to monorepo root (3 levels up from apps/companion/main)
|
||||
// Level 0: apps/companion/main (__dirname)
|
||||
// Level 1: apps/companion (../)
|
||||
// Level 2: apps (../../)
|
||||
// Level 3: gridpilot (monorepo root) (../../../) ← CORRECT
|
||||
const expectedPath = '/Users/test/Projects/gridpilot/resources/iracing-hosted-sessions';
|
||||
expect(resolved).toBe(expectedPath);
|
||||
});
|
||||
|
||||
it('should navigate exactly 3 levels up in dev mode', () => {
|
||||
// Given: A path without /dist/ that demonstrates the 3-level navigation
|
||||
const mockDirname = '/level3/level2/level1';
|
||||
const relativePath = './target';
|
||||
|
||||
// When: Resolving the fixtures path
|
||||
const resolved = resolveFixturesPath(relativePath, mockDirname);
|
||||
|
||||
// Then: Should resolve to the path 3 levels up (root /)
|
||||
expect(resolved).toBe('/target');
|
||||
});
|
||||
|
||||
it('should work with different relative path formats in dev mode', () => {
|
||||
// Given: Various relative path formats
|
||||
const mockDirname = '/Users/test/Projects/gridpilot/apps/companion/main';
|
||||
|
||||
// When/Then: Different relative formats should all work
|
||||
expect(resolveFixturesPath('resources/fixtures', mockDirname))
|
||||
.toBe('/Users/test/Projects/gridpilot/resources/fixtures');
|
||||
|
||||
expect(resolveFixturesPath('./resources/fixtures', mockDirname))
|
||||
.toBe('/Users/test/Projects/gridpilot/resources/fixtures');
|
||||
});
|
||||
});
|
||||
|
||||
describe('absolute paths', () => {
|
||||
it('should return absolute paths unchanged in built mode', () => {
|
||||
// Given: An absolute path
|
||||
const absolutePath = '/some/absolute/path/to/fixtures';
|
||||
const mockDirname = '/Users/test/Projects/gridpilot/apps/companion/dist/main';
|
||||
|
||||
// When: Resolving an absolute path
|
||||
const resolved = resolveFixturesPath(absolutePath, mockDirname);
|
||||
|
||||
// Then: Should return the absolute path unchanged
|
||||
expect(resolved).toBe(absolutePath);
|
||||
});
|
||||
|
||||
it('should return absolute paths unchanged in dev mode', () => {
|
||||
// Given: An absolute path
|
||||
const absolutePath = '/some/absolute/path/to/fixtures';
|
||||
const mockDirname = '/Users/test/Projects/gridpilot/apps/companion/main';
|
||||
|
||||
// When: Resolving an absolute path
|
||||
const resolved = resolveFixturesPath(absolutePath, mockDirname);
|
||||
|
||||
// Then: Should return the absolute path unchanged
|
||||
expect(resolved).toBe(absolutePath);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,386 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { BrowserDevToolsAdapter, DevToolsConfig } from '../../../packages/infrastructure/adapters/automation/BrowserDevToolsAdapter';
|
||||
import { StepId } from '../../../packages/domain/value-objects/StepId';
|
||||
import {
|
||||
IRacingSelectorMap,
|
||||
getStepSelectors,
|
||||
getStepName,
|
||||
isModalStep,
|
||||
} from '../../../packages/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);
|
||||
});
|
||||
});
|
||||
@@ -181,7 +181,7 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => {
|
||||
const result = await adapter.executeStep(stepId, config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stepId).toBe(1);
|
||||
expect(result.metadata?.stepId).toBe(1);
|
||||
});
|
||||
|
||||
it('should execute step 6 (modal step)', async () => {
|
||||
@@ -195,8 +195,8 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => {
|
||||
const result = await adapter.executeStep(stepId, config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stepId).toBe(6);
|
||||
expect(result.wasModalStep).toBe(true);
|
||||
expect(result.metadata?.stepId).toBe(6);
|
||||
expect(result.metadata?.wasModalStep).toBe(true);
|
||||
});
|
||||
|
||||
it('should execute step 18 (final step)', async () => {
|
||||
@@ -210,8 +210,8 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => {
|
||||
const result = await adapter.executeStep(stepId, config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stepId).toBe(18);
|
||||
expect(result.shouldStop).toBe(true);
|
||||
expect(result.metadata?.stepId).toBe(18);
|
||||
expect(result.metadata?.shouldStop).toBe(true);
|
||||
});
|
||||
|
||||
it('should simulate realistic step execution times', async () => {
|
||||
@@ -224,7 +224,7 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => {
|
||||
|
||||
const result = await adapter.executeStep(stepId, config);
|
||||
|
||||
expect(result.executionTime).toBeGreaterThan(0);
|
||||
expect(result.metadata?.executionTime).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -274,9 +274,9 @@ describe('MockBrowserAutomationAdapter Integration Tests', () => {
|
||||
|
||||
const result = await adapter.executeStep(stepId, config);
|
||||
|
||||
expect(result.metrics).toBeDefined();
|
||||
expect(result.metrics.totalDelay).toBeGreaterThan(0);
|
||||
expect(result.metrics.operationCount).toBeGreaterThan(0);
|
||||
expect(result.metadata).toBeDefined();
|
||||
expect(result.metadata?.totalDelay).toBeGreaterThan(0);
|
||||
expect(result.metadata?.operationCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,15 +16,6 @@ describe('AutomationConfig', () => {
|
||||
|
||||
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;
|
||||
@@ -43,33 +34,42 @@ describe('AutomationConfig', () => {
|
||||
expect(mode).toBe('test');
|
||||
});
|
||||
|
||||
it('should return development mode when NODE_ENV is not set', () => {
|
||||
it('should return test mode when NODE_ENV is not set', () => {
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const mode = getAutomationMode();
|
||||
|
||||
expect(mode).toBe('development');
|
||||
expect(mode).toBe('test');
|
||||
});
|
||||
|
||||
it('should return development mode for unknown NODE_ENV values', () => {
|
||||
it('should return test mode for unknown NODE_ENV values', () => {
|
||||
process.env.NODE_ENV = 'staging';
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const mode = getAutomationMode();
|
||||
|
||||
expect(mode).toBe('development');
|
||||
expect(mode).toBe('test');
|
||||
});
|
||||
|
||||
it('should return test mode when NODE_ENV=development', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const mode = getAutomationMode();
|
||||
|
||||
expect(mode).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy AUTOMATION_MODE support', () => {
|
||||
it('should map legacy dev mode to development with deprecation warning', () => {
|
||||
it('should map legacy dev mode to test with deprecation warning', () => {
|
||||
process.env.AUTOMATION_MODE = 'dev';
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const mode = getAutomationMode();
|
||||
|
||||
expect(mode).toBe('development');
|
||||
expect(mode).toBe('test');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[DEPRECATED] AUTOMATION_MODE')
|
||||
);
|
||||
@@ -115,20 +115,13 @@ describe('AutomationConfig', () => {
|
||||
|
||||
describe('loadAutomationConfig', () => {
|
||||
describe('default configuration', () => {
|
||||
it('should return development mode when NODE_ENV is not set', () => {
|
||||
it('should return test mode when NODE_ENV is not set', () => {
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.mode).toBe('development');
|
||||
});
|
||||
|
||||
it('should return default devTools configuration', () => {
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
expect(config.devTools?.debuggingPort).toBe(9222);
|
||||
expect(config.devTools?.browserWSEndpoint).toBeUndefined();
|
||||
expect(config.mode).toBe('test');
|
||||
});
|
||||
|
||||
it('should return default nutJs configuration', () => {
|
||||
@@ -146,44 +139,6 @@ describe('AutomationConfig', () => {
|
||||
expect(config.retryAttempts).toBe(3);
|
||||
expect(config.screenshotOnError).toBe(true);
|
||||
});
|
||||
|
||||
it('should return default fixture server configuration', () => {
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
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', () => {
|
||||
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', () => {
|
||||
@@ -255,13 +210,11 @@ describe('AutomationConfig', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -274,109 +227,17 @@ describe('AutomationConfig', () => {
|
||||
expect(config.nutJs?.confidence).toBe(0.9);
|
||||
});
|
||||
|
||||
it('should fallback to development mode for invalid NODE_ENV', () => {
|
||||
it('should fallback to test mode for invalid NODE_ENV', () => {
|
||||
process.env.NODE_ENV = 'invalid-env';
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
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);
|
||||
expect(config.mode).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('full configuration scenario', () => {
|
||||
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: 'development',
|
||||
devTools: {
|
||||
debuggingPort: 9222,
|
||||
browserWSEndpoint: 'ws://localhost:9222/devtools/browser/test',
|
||||
},
|
||||
nutJs: {
|
||||
mouseSpeed: 1000,
|
||||
keyboardDelay: 50,
|
||||
windowTitle: 'iRacing',
|
||||
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 test environment configuration', () => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
delete process.env.AUTOMATION_MODE;
|
||||
@@ -384,10 +245,7 @@ describe('AutomationConfig', () => {
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
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', () => {
|
||||
@@ -397,10 +255,7 @@ describe('AutomationConfig', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user