alpha wip
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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 { StepTransitionValidator } from '../../../../domain/services/StepTransitionValidator';
|
||||
|
||||
/**
|
||||
* Real Automation Engine Adapter.
|
||||
*
|
||||
* Orchestrates the automation workflow by:
|
||||
* 1. Validating session configuration
|
||||
* 2. Executing each step using real browser automation
|
||||
* 3. Managing session state transitions
|
||||
*
|
||||
* This is a REAL implementation that uses actual automation,
|
||||
* not a mock. Historically delegated to legacy native screen
|
||||
* automation adapters, but those are no longer part of the
|
||||
* supported stack.
|
||||
*
|
||||
* @deprecated This adapter should be updated to use Playwright
|
||||
* browser automation when available. See docs/ARCHITECTURE.md
|
||||
* for the updated automation strategy.
|
||||
*/
|
||||
export class AutomationEngineAdapter implements IAutomationEngine {
|
||||
private isRunning = false;
|
||||
private automationPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly browserAutomation: IBrowserAutomation,
|
||||
private readonly sessionRepository: ISessionRepository
|
||||
) {}
|
||||
|
||||
async validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult> {
|
||||
if (!config.sessionName || config.sessionName.trim() === '') {
|
||||
return { isValid: false, error: 'Session name is required' };
|
||||
}
|
||||
if (!config.trackId || config.trackId.trim() === '') {
|
||||
return { isValid: false, error: 'Track ID is required' };
|
||||
}
|
||||
if (!config.carIds || config.carIds.length === 0) {
|
||||
return { isValid: false, error: 'At least one car must be selected' };
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
async executeStep(stepId: StepId, config: HostedSessionConfig): Promise<void> {
|
||||
const sessions = await this.sessionRepository.findAll();
|
||||
const session = sessions[0];
|
||||
if (!session) {
|
||||
throw new Error('No active session found');
|
||||
}
|
||||
|
||||
// Start session if it's at step 1 and pending
|
||||
if (session.state.isPending() && stepId.value === 1) {
|
||||
session.start();
|
||||
await this.sessionRepository.update(session);
|
||||
|
||||
// Start automated progression
|
||||
this.startAutomation(config);
|
||||
}
|
||||
}
|
||||
|
||||
private startAutomation(config: HostedSessionConfig): void {
|
||||
if (this.isRunning) {
|
||||
return;
|
||||
}
|
||||
this.isRunning = true;
|
||||
this.automationPromise = this.runAutomationLoop(config);
|
||||
}
|
||||
|
||||
private async runAutomationLoop(config: HostedSessionConfig): Promise<void> {
|
||||
while (this.isRunning) {
|
||||
try {
|
||||
const sessions = await this.sessionRepository.findAll();
|
||||
const session = sessions[0];
|
||||
|
||||
if (!session || !session.state.isInProgress()) {
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStep = session.currentStep;
|
||||
|
||||
// Execute current step using the browser automation
|
||||
if (this.browserAutomation.executeStep) {
|
||||
const result = await this.browserAutomation.executeStep(currentStep, config as unknown as Record<string, unknown>);
|
||||
if (!result.success) {
|
||||
const stepDescription = StepTransitionValidator.getStepDescription(currentStep);
|
||||
const errorMessage = `Step ${currentStep.value} (${stepDescription}) failed: ${result.error}`;
|
||||
console.error(errorMessage);
|
||||
|
||||
// Stop automation and mark session as failed
|
||||
this.isRunning = false;
|
||||
|
||||
session.fail(errorMessage);
|
||||
await this.sessionRepository.update(session);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Fallback for adapters without executeStep
|
||||
await this.browserAutomation.navigateToPage(`step-${currentStep.value}`);
|
||||
}
|
||||
|
||||
// Transition to next step
|
||||
if (!currentStep.isFinalStep()) {
|
||||
session.transitionToStep(currentStep.next());
|
||||
await this.sessionRepository.update(session);
|
||||
|
||||
// If we just transitioned to the final step, execute it before stopping
|
||||
const nextStep = session.currentStep;
|
||||
if (nextStep.isFinalStep()) {
|
||||
// Execute final step handler
|
||||
if (this.browserAutomation.executeStep) {
|
||||
const result = await this.browserAutomation.executeStep(nextStep, config as unknown as Record<string, unknown>);
|
||||
if (!result.success) {
|
||||
const stepDescription = StepTransitionValidator.getStepDescription(nextStep);
|
||||
const errorMessage = `Step ${nextStep.value} (${stepDescription}) failed: ${result.error}`;
|
||||
console.error(errorMessage);
|
||||
// Don't try to fail terminal session - just log the error
|
||||
// Session is already in STOPPED_AT_STEP_18 state after transitionToStep()
|
||||
}
|
||||
}
|
||||
// Stop after final step
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Current step is already final - stop
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait before next iteration
|
||||
await this.delay(500);
|
||||
} catch (error) {
|
||||
console.error('Automation error:', error);
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
public stopAutomation(): void {
|
||||
this.isRunning = false;
|
||||
this.automationPromise = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface IFixtureServer {
|
||||
start(port?: number): Promise<{ url: string; port: number }>;
|
||||
stop(): Promise<void>;
|
||||
getFixtureUrl(stepNumber: number): string;
|
||||
isRunning(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step number to fixture file mapping.
|
||||
* Steps 1-18 map to the corresponding HTML fixture files.
|
||||
*/
|
||||
const STEP_TO_FIXTURE: Record<number, string> = {
|
||||
1: '01-hosted-racing.html',
|
||||
2: '02-create-a-race.html',
|
||||
3: '03-race-information.html',
|
||||
4: '04-server-details.html',
|
||||
5: '05-set-admins.html',
|
||||
6: '06-add-an-admin.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',
|
||||
};
|
||||
|
||||
export class FixtureServer implements IFixtureServer {
|
||||
private server: http.Server | null = null;
|
||||
private port: number = 3456;
|
||||
private fixturesPath: string;
|
||||
|
||||
constructor(fixturesPath?: string) {
|
||||
this.fixturesPath =
|
||||
fixturesPath ?? path.resolve(process.cwd(), 'html-dumps/iracing-hosted-sessions');
|
||||
}
|
||||
|
||||
async start(port: number = 3456): Promise<{ url: string; port: number }> {
|
||||
if (this.server) {
|
||||
return { url: `http://localhost:${this.port}`, port: this.port };
|
||||
}
|
||||
|
||||
this.port = port;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = http.createServer((req, res) => {
|
||||
this.handleRequest(req, res);
|
||||
});
|
||||
|
||||
this.server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
this.server = null;
|
||||
this.start(port + 1).then(resolve).catch(reject);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
this.server.listen(this.port, () => {
|
||||
resolve({ url: `http://localhost:${this.port}`, port: this.port });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server!.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
this.server = null;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getFixtureUrl(stepNumber: number): string {
|
||||
const fixture = STEP_TO_FIXTURE[stepNumber];
|
||||
if (!fixture) {
|
||||
return `http://localhost:${this.port}/`;
|
||||
}
|
||||
return `http://localhost:${this.port}/${fixture}`;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.server !== null;
|
||||
}
|
||||
|
||||
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
const urlPath = req.url || '/';
|
||||
|
||||
let fileName: string;
|
||||
if (urlPath === '/') {
|
||||
fileName = STEP_TO_FIXTURE[1];
|
||||
} else {
|
||||
fileName = urlPath.replace(/^\//, '');
|
||||
|
||||
const legacyMatch = fileName.match(/^step-(\d+)-/);
|
||||
if (legacyMatch) {
|
||||
const stepNum = Number(legacyMatch[1]);
|
||||
const mapped = STEP_TO_FIXTURE[stepNum];
|
||||
if (mapped) {
|
||||
fileName = mapped;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filePath = path.join(this.fixturesPath, fileName);
|
||||
|
||||
// Security check - prevent directory traversal
|
||||
if (!filePath.startsWith(this.fixturesPath)) {
|
||||
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||
res.end('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
const errno = (err as NodeJS.ErrnoException).code;
|
||||
if (errno === 'ENOENT') {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('Not Found');
|
||||
} else {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentTypes: Record<string, string> = {
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
const contentType = contentTypes[ext] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fixture filename for a given step number.
|
||||
*/
|
||||
export function getFixtureForStep(stepNumber: number): string | undefined {
|
||||
return STEP_TO_FIXTURE[stepNumber];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all step-to-fixture mappings.
|
||||
*/
|
||||
export function getAllStepFixtureMappings(): Record<number, string> {
|
||||
return { ...STEP_TO_FIXTURE };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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 { StepTransitionValidator } from '../../../../domain/services/StepTransitionValidator';
|
||||
|
||||
export class MockAutomationEngineAdapter implements IAutomationEngine {
|
||||
private isRunning = false;
|
||||
private automationPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly browserAutomation: IBrowserAutomation,
|
||||
private readonly sessionRepository: ISessionRepository
|
||||
) {}
|
||||
|
||||
async validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult> {
|
||||
if (!config.sessionName || config.sessionName.trim() === '') {
|
||||
return { isValid: false, error: 'Session name is required' };
|
||||
}
|
||||
if (!config.trackId || config.trackId.trim() === '') {
|
||||
return { isValid: false, error: 'Track ID is required' };
|
||||
}
|
||||
if (!config.carIds || config.carIds.length === 0) {
|
||||
return { isValid: false, error: 'At least one car must be selected' };
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
async executeStep(stepId: StepId, config: HostedSessionConfig): Promise<void> {
|
||||
const sessions = await this.sessionRepository.findAll();
|
||||
const session = sessions[0];
|
||||
if (!session) {
|
||||
throw new Error('No active session found');
|
||||
}
|
||||
|
||||
// Start session if it's at step 1 and pending
|
||||
if (session.state.isPending() && stepId.value === 1) {
|
||||
session.start();
|
||||
await this.sessionRepository.update(session);
|
||||
|
||||
// Start automated progression
|
||||
this.startAutomation(config);
|
||||
}
|
||||
}
|
||||
|
||||
private startAutomation(config: HostedSessionConfig): void {
|
||||
if (this.isRunning) {
|
||||
return;
|
||||
}
|
||||
this.isRunning = true;
|
||||
this.automationPromise = this.runAutomationLoop(config);
|
||||
}
|
||||
|
||||
private async runAutomationLoop(config: HostedSessionConfig): Promise<void> {
|
||||
while (this.isRunning) {
|
||||
try {
|
||||
const sessions = await this.sessionRepository.findAll();
|
||||
const session = sessions[0];
|
||||
|
||||
if (!session || !session.state.isInProgress()) {
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStep = session.currentStep;
|
||||
|
||||
// Execute current step using the browser automation
|
||||
if (this.browserAutomation.executeStep) {
|
||||
const result = await this.browserAutomation.executeStep(
|
||||
currentStep,
|
||||
config as unknown as Record<string, unknown>,
|
||||
);
|
||||
if (!result.success) {
|
||||
const stepDescription = StepTransitionValidator.getStepDescription(currentStep);
|
||||
const errorMessage = `Step ${currentStep.value} (${stepDescription}) failed: ${result.error}`;
|
||||
console.error(errorMessage);
|
||||
|
||||
// Stop automation and mark session as failed
|
||||
this.isRunning = false;
|
||||
|
||||
session.fail(errorMessage);
|
||||
await this.sessionRepository.update(session);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Fallback for adapters without executeStep (e.g., MockBrowserAutomationAdapter)
|
||||
await this.browserAutomation.navigateToPage(`step-${currentStep.value}`);
|
||||
}
|
||||
|
||||
// Transition to next step
|
||||
if (!currentStep.isFinalStep()) {
|
||||
session.transitionToStep(currentStep.next());
|
||||
await this.sessionRepository.update(session);
|
||||
|
||||
// If we just transitioned to the final step, execute it before stopping
|
||||
const nextStep = session.currentStep;
|
||||
if (nextStep.isFinalStep()) {
|
||||
// Execute final step handler
|
||||
if (this.browserAutomation.executeStep) {
|
||||
const result = await this.browserAutomation.executeStep(
|
||||
nextStep,
|
||||
config as unknown as Record<string, unknown>,
|
||||
);
|
||||
if (!result.success) {
|
||||
const stepDescription = StepTransitionValidator.getStepDescription(nextStep);
|
||||
const errorMessage = `Step ${nextStep.value} (${stepDescription}) failed: ${result.error}`;
|
||||
console.error(errorMessage);
|
||||
// Don't try to fail terminal session - just log the error
|
||||
// Session is already in STOPPED_AT_STEP_18 state after transitionToStep()
|
||||
}
|
||||
}
|
||||
// Stop after final step
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Current step is already final - stop
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait before next iteration
|
||||
await this.delay(500);
|
||||
} catch (error) {
|
||||
console.error('Automation error:', error);
|
||||
this.isRunning = false;
|
||||
|
||||
try {
|
||||
const sessions = await this.sessionRepository.findAll();
|
||||
const session = sessions[0];
|
||||
if (session && !session.state.isTerminal()) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
session.fail(`Automation error: ${message}`);
|
||||
await this.sessionRepository.update(session);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
public stopAutomation(): void {
|
||||
this.isRunning = false;
|
||||
this.automationPromise = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||
import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation';
|
||||
import {
|
||||
NavigationResult,
|
||||
FormFillResult,
|
||||
ClickResult,
|
||||
WaitResult,
|
||||
ModalResult,
|
||||
AutomationResult,
|
||||
} from '../../../../application/ports/AutomationResults';
|
||||
|
||||
interface MockConfig {
|
||||
simulateFailures?: boolean;
|
||||
failureRate?: number;
|
||||
}
|
||||
|
||||
interface StepExecutionResult {
|
||||
success: boolean;
|
||||
stepId: number;
|
||||
wasModalStep?: boolean;
|
||||
shouldStop?: boolean;
|
||||
executionTime: number;
|
||||
metrics: {
|
||||
totalDelay: number;
|
||||
operationCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class MockBrowserAutomationAdapter implements IBrowserAutomation {
|
||||
private config: MockConfig;
|
||||
private connected: boolean = false;
|
||||
|
||||
constructor(config: MockConfig = {}) {
|
||||
this.config = {
|
||||
simulateFailures: config.simulateFailures ?? false,
|
||||
failureRate: config.failureRate ?? 0.1,
|
||||
};
|
||||
}
|
||||
|
||||
async connect(): Promise<AutomationResult> {
|
||||
this.connected = true;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
async navigateToPage(url: string): Promise<NavigationResult> {
|
||||
const delay = this.randomDelay(200, 800);
|
||||
await this.sleep(delay);
|
||||
return {
|
||||
success: true,
|
||||
url,
|
||||
loadTime: delay,
|
||||
};
|
||||
}
|
||||
|
||||
async fillFormField(fieldName: string, value: string): Promise<FormFillResult> {
|
||||
const delay = this.randomDelay(100, 500);
|
||||
await this.sleep(delay);
|
||||
return {
|
||||
success: true,
|
||||
fieldName,
|
||||
valueSet: value,
|
||||
};
|
||||
}
|
||||
|
||||
async clickElement(selector: string): Promise<ClickResult> {
|
||||
const delay = this.randomDelay(50, 300);
|
||||
await this.sleep(delay);
|
||||
return {
|
||||
success: true,
|
||||
target: selector,
|
||||
};
|
||||
}
|
||||
|
||||
async waitForElement(selector: string, maxWaitMs: number = 5000): Promise<WaitResult> {
|
||||
const delay = this.randomDelay(100, 1000);
|
||||
|
||||
await this.sleep(delay);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
target: selector,
|
||||
waitedMs: delay,
|
||||
found: true,
|
||||
};
|
||||
}
|
||||
|
||||
async handleModal(stepId: StepId, action: string): Promise<ModalResult> {
|
||||
if (!stepId.isModalStep()) {
|
||||
throw new Error(`Step ${stepId.value} is not a modal step`);
|
||||
}
|
||||
|
||||
const delay = this.randomDelay(200, 600);
|
||||
await this.sleep(delay);
|
||||
return {
|
||||
success: true,
|
||||
stepId: stepId.value,
|
||||
action,
|
||||
};
|
||||
}
|
||||
|
||||
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
|
||||
if (this.shouldSimulateFailure()) {
|
||||
throw new Error(`Simulated failure at step ${stepId.value}`);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
let totalDelay = 0;
|
||||
let operationCount = 0;
|
||||
|
||||
const navigationDelay = this.randomDelay(200, 500);
|
||||
await this.sleep(navigationDelay);
|
||||
totalDelay += navigationDelay;
|
||||
operationCount++;
|
||||
|
||||
if (stepId.isModalStep()) {
|
||||
const modalDelay = this.randomDelay(200, 400);
|
||||
await this.sleep(modalDelay);
|
||||
totalDelay += modalDelay;
|
||||
operationCount++;
|
||||
}
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
metadata: {
|
||||
stepId: stepId.value,
|
||||
wasModalStep: stepId.isModalStep(),
|
||||
shouldStop: stepId.isFinalStep(),
|
||||
executionTime,
|
||||
totalDelay,
|
||||
operationCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private randomDelay(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
private async sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private shouldSimulateFailure(): boolean {
|
||||
if (!this.config.simulateFailures) {
|
||||
return false;
|
||||
}
|
||||
return Math.random() < (this.config.failureRate || 0.1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user