feat(automation): implement real executeStep() with template-based OS automation

This commit is contained in:
2025-11-22 14:18:14 +01:00
parent 265b070606
commit 56b7b2594f

View File

@@ -16,7 +16,7 @@ import { NoOpLogAdapter } from '../logging/NoOpLogAdapter';
import { ScreenRecognitionService } from './ScreenRecognitionService';
import { TemplateMatchingService } from './TemplateMatchingService';
import { WindowFocusService } from './WindowFocusService';
import { getLoginIndicators, getLogoutIndicators } from './templates/IRacingTemplateMap';
import { getLoginIndicators, getLogoutIndicators, getStepTemplates, getStepName, type StepTemplates } from './templates/IRacingTemplateMap';
export interface NutJsConfig {
mouseSpeed?: number;
@@ -289,60 +289,355 @@ export class NutJsAutomationAdapter implements IScreenAutomation {
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
const stepNumber = stepId.value;
const startTime = Date.now();
const stepName = getStepName(stepNumber);
this.logger.info('Executing step via OS-level automation', { stepId: stepNumber });
this.logger.info('Executing step via OS-level automation', { stepId: stepNumber, stepName });
try {
switch (stepNumber) {
case 1:
this.logger.debug('Skipping login step - user pre-authenticated', { stepId: stepNumber });
return {
success: true,
metadata: {
skipped: true,
reason: 'User pre-authenticated',
step: 'LOGIN',
},
};
case 18:
this.logger.info('Safety stop at final step', { stepId: stepNumber });
return {
success: true,
metadata: {
step: 'TRACK_CONDITIONS',
safetyStop: true,
message: 'Automation stopped at final step. User must review configuration and click checkout manually.',
},
};
default: {
const durationMs = Date.now() - startTime;
this.logger.info('Step executed successfully', { stepId: stepNumber, durationMs });
return {
success: true,
metadata: {
step: `STEP_${stepNumber}`,
message: `Step ${stepNumber} executed via OS-level automation`,
config,
},
};
}
// Step 1: LOGIN - Skip (user handles manually)
if (stepNumber === 1) {
this.logger.debug('Skipping login step - user pre-authenticated', { stepId: stepNumber });
return {
success: true,
metadata: {
skipped: true,
reason: 'User pre-authenticated',
step: stepName,
},
};
}
// Step 18: TRACK_CONDITIONS - Safety stop before checkout
if (stepNumber === 18) {
this.logger.info('Safety stop at final step', { stepId: stepNumber });
return {
success: true,
metadata: {
step: stepName,
safetyStop: true,
message: 'Automation stopped at final step. User must review configuration and click checkout manually.',
},
};
}
// Steps 2-17: Real automation
// 1. Focus browser window
const focusResult = await this.windowFocus.focusBrowserWindow();
if (!focusResult.success) {
this.logger.warn('Failed to focus browser window, continuing anyway', { error: focusResult.error });
}
// Small delay after focusing
await this.delay(200);
// 2. Get templates for this step
const stepTemplates = getStepTemplates(stepNumber);
if (!stepTemplates) {
this.logger.warn('No templates defined for step', { stepId: stepNumber, stepName });
return {
success: false,
error: `No templates defined for step ${stepNumber} (${stepName})`,
metadata: { step: stepName },
};
}
// 3. Execute step-specific automation
const result = await this.executeStepActions(stepNumber, stepName, stepTemplates, config);
const durationMs = Date.now() - startTime;
this.logger.info('Step execution completed', { stepId: stepNumber, stepName, durationMs, success: result.success });
return {
...result,
metadata: {
...result.metadata,
step: stepName,
durationMs,
},
};
} catch (error) {
const durationMs = Date.now() - startTime;
this.logger.error('Step execution failed', error instanceof Error ? error : new Error(String(error)), {
stepId: stepNumber,
stepName,
durationMs
});
return {
success: false,
error: String(error),
metadata: { step: `STEP_${stepNumber}` },
metadata: { step: stepName, durationMs },
};
}
}
/**
* Execute step-specific actions based on the step number.
*/
private async executeStepActions(
stepNumber: number,
stepName: string,
templates: StepTemplates,
config: Record<string, unknown>
): Promise<AutomationResult> {
switch (stepNumber) {
// Step 2: HOSTED_RACING - Click "Create a Race" button
case 2:
return this.executeClickStep(templates, 'createRace', 'Navigate to hosted racing');
// Step 3: CREATE_RACE - Confirm race creation modal
case 3:
return this.executeClickStep(templates, 'confirm', 'Confirm race creation');
// Step 4: RACE_INFORMATION - Fill session name and details, then click next
case 4:
return this.executeFormStep(templates, config, [
{ field: 'sessionName', configKey: 'sessionName' },
{ field: 'password', configKey: 'sessionPassword' },
{ field: 'description', configKey: 'description' },
], 'next');
// Step 5: SERVER_DETAILS - Configure server settings, then click next
case 5:
return this.executeFormStep(templates, config, [
{ field: 'region', configKey: 'serverRegion' },
], 'next');
// Step 6: SET_ADMINS - Modal step for adding admins
case 6:
return this.executeModalStep(templates, config, 'adminName', 'next');
// Step 7: TIME_LIMITS - Fill time fields
case 7:
return this.executeFormStep(templates, config, [
{ field: 'practice', configKey: 'practiceLength' },
{ field: 'qualify', configKey: 'qualifyLength' },
{ field: 'race', configKey: 'raceLength' },
], 'next');
// Step 8: SET_CARS - Click add car button
case 8:
return this.executeClickStep(templates, 'addCar', 'Open car selection');
// Step 9: ADD_CAR - Modal for car selection
case 9:
return this.executeModalStep(templates, config, 'carName', 'select');
// Step 10: SET_CAR_CLASSES - Configure car classes
case 10:
return this.executeFormStep(templates, config, [
{ field: 'class', configKey: 'carClass' },
], 'next');
// Step 11: SET_TRACK - Click add track button
case 11:
return this.executeClickStep(templates, 'addTrack', 'Open track selection');
// Step 12: ADD_TRACK - Modal for track selection
case 12:
return this.executeModalStep(templates, config, 'trackName', 'select');
// Step 13: TRACK_OPTIONS - Configure track options
case 13:
return this.executeFormStep(templates, config, [
{ field: 'config', configKey: 'trackConfig' },
], 'next');
// Step 14: TIME_OF_DAY - Configure time settings
case 14:
return this.executeFormStep(templates, config, [
{ field: 'time', configKey: 'timeOfDay' },
{ field: 'date', configKey: 'raceDate' },
], 'next');
// Step 15: WEATHER - Configure weather settings
case 15:
return this.executeFormStep(templates, config, [
{ field: 'weather', configKey: 'weatherType' },
{ field: 'temperature', configKey: 'temperature' },
], 'next');
// Step 16: RACE_OPTIONS - Configure race options
case 16:
return this.executeFormStep(templates, config, [
{ field: 'maxDrivers', configKey: 'maxDrivers' },
{ field: 'rollingStart', configKey: 'rollingStart' },
], 'next');
// Step 17: TEAM_DRIVING - Configure team settings
case 17:
return this.executeFormStep(templates, config, [
{ field: 'teamDriving', configKey: 'teamDriving' },
], 'next');
default:
this.logger.warn('Unhandled step number', { stepNumber, stepName });
return {
success: false,
error: `No automation handler for step ${stepNumber} (${stepName})`,
};
}
}
/**
* Execute a simple click step - find and click a button.
*/
private async executeClickStep(
templates: StepTemplates,
buttonKey: string,
actionDescription: string
): Promise<AutomationResult> {
const buttonTemplate = templates.buttons[buttonKey];
if (!buttonTemplate) {
this.logger.warn('Button template not defined', { buttonKey });
return {
success: false,
error: `Button template '${buttonKey}' not defined for this step`,
};
}
// Find the button on screen
const location = await this.templateMatching.findElement(buttonTemplate);
if (!location) {
this.logger.warn('Button not found on screen', {
buttonKey,
templateId: buttonTemplate.id,
description: buttonTemplate.description
});
return {
success: false,
error: `Button '${buttonKey}' not found on screen (template: ${buttonTemplate.id})`,
metadata: { templateId: buttonTemplate.id, action: actionDescription },
};
}
// Click the button
const clickResult = await this.clickAtLocation(location);
if (!clickResult.success) {
return {
success: false,
error: `Failed to click button '${buttonKey}': ${clickResult.error}`,
metadata: { templateId: buttonTemplate.id, action: actionDescription },
};
}
// Small delay after clicking
await this.delay(300);
return {
success: true,
metadata: {
action: actionDescription,
templateId: buttonTemplate.id,
clickLocation: location.center,
},
};
}
/**
* Execute a form step - fill fields and click next.
*/
private async executeFormStep(
templates: StepTemplates,
config: Record<string, unknown>,
fieldMappings: Array<{ field: string; configKey: string }>,
nextButtonKey: string
): Promise<AutomationResult> {
const filledFields: string[] = [];
const skippedFields: string[] = [];
// Process each field mapping
for (const mapping of fieldMappings) {
const fieldTemplate = templates.fields?.[mapping.field];
const configValue = config[mapping.configKey];
// Skip if no value provided in config
if (configValue === undefined || configValue === null || configValue === '') {
skippedFields.push(mapping.field);
continue;
}
// Skip if no template defined
if (!fieldTemplate) {
this.logger.debug('Field template not defined, skipping', { field: mapping.field });
skippedFields.push(mapping.field);
continue;
}
// Find the field on screen
const location = await this.templateMatching.findElement(fieldTemplate);
if (!location) {
this.logger.warn('Field not found on screen', {
field: mapping.field,
templateId: fieldTemplate.id
});
skippedFields.push(mapping.field);
continue;
}
// Click the field to focus it
await this.clickAtLocation(location);
await this.delay(100);
// Fill the field
const fillResult = await this.fillFormField(mapping.field, String(configValue));
if (fillResult.success) {
filledFields.push(mapping.field);
} else {
this.logger.warn('Failed to fill field', { field: mapping.field, error: fillResult.error });
skippedFields.push(mapping.field);
}
await this.delay(100);
}
// Click the next button
const nextResult = await this.executeClickStep(templates, nextButtonKey, 'Proceed to next step');
return {
success: nextResult.success,
error: nextResult.error,
metadata: {
...nextResult.metadata,
filledFields,
skippedFields,
},
};
}
/**
* Execute a modal step - interact with a modal dialog.
*/
private async executeModalStep(
templates: StepTemplates,
config: Record<string, unknown>,
searchConfigKey: string,
confirmButtonKey: string
): Promise<AutomationResult> {
// If modal has search input and we have a search value, use it
if (templates.modal?.searchInput) {
const searchValue = config[searchConfigKey];
if (searchValue && typeof searchValue === 'string') {
const searchLocation = await this.templateMatching.findElement(templates.modal.searchInput);
if (searchLocation) {
await this.clickAtLocation(searchLocation);
await this.delay(100);
await this.fillFormField('search', searchValue);
await this.delay(500); // Wait for search results
}
}
}
// Click the confirm/select button
return this.executeClickStep(templates, confirmButtonKey, 'Confirm modal selection');
}
private parseTarget(target: string): Point {
if (target.includes(',')) {
const [x, y] = target.split(',').map(Number);