wip
This commit is contained in:
@@ -9,6 +9,8 @@ interface Page {
|
||||
}
|
||||
|
||||
interface Locator {
|
||||
first(): Locator;
|
||||
locator(selector: string): Locator;
|
||||
getAttribute(name: string): Promise<string | null>;
|
||||
innerHTML(): Promise<string>;
|
||||
textContent(): Promise<string | null>;
|
||||
|
||||
@@ -277,14 +277,13 @@ export class SessionCookieStore {
|
||||
validateCookieConfiguration(targetUrl: string): Result<Cookie[]> {
|
||||
try {
|
||||
if (!this.cachedState || this.cachedState.cookies.length === 0) {
|
||||
return Result.err('No cookies found in session store');
|
||||
return Result.err<Cookie[]>(new Error('No cookies found in session store'));
|
||||
}
|
||||
|
||||
const result = this.validateCookiesForUrl(this.cachedState.cookies, targetUrl, true);
|
||||
return result;
|
||||
return this.validateCookiesForUrl(this.cachedState.cookies, targetUrl, true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Result.err(`Cookie validation failed: ${message}`);
|
||||
return Result.err<Cookie[]>(new Error(`Cookie validation failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,62 +298,57 @@ export class SessionCookieStore {
|
||||
requireAuthCookies = false
|
||||
): Result<Cookie[]> {
|
||||
try {
|
||||
// Validate each cookie's domain/path
|
||||
const validatedCookies: Cookie[] = [];
|
||||
let firstValidationError: string | null = null;
|
||||
let firstValidationError: Error | null = null;
|
||||
|
||||
for (const cookie of cookies) {
|
||||
try {
|
||||
new CookieConfiguration(cookie, targetUrl);
|
||||
validatedCookies.push(cookie);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Capture first validation error to return if all cookies fail
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (!firstValidationError) {
|
||||
firstValidationError = message;
|
||||
firstValidationError = err;
|
||||
}
|
||||
|
||||
|
||||
this.logger?.warn('Cookie validation failed', {
|
||||
name: cookie.name,
|
||||
error: message,
|
||||
error: err.message,
|
||||
});
|
||||
// Skip invalid cookie, continue with others
|
||||
}
|
||||
}
|
||||
|
||||
if (validatedCookies.length === 0) {
|
||||
// Return the specific validation error from the first failed cookie
|
||||
return Result.err(firstValidationError || 'No valid cookies found for target URL');
|
||||
return Result.err<Cookie[]>(
|
||||
firstValidationError ?? new Error('No valid cookies found for target URL')
|
||||
);
|
||||
}
|
||||
|
||||
// Check required cookies only if requested (for authentication validation)
|
||||
if (requireAuthCookies) {
|
||||
const cookieNames = validatedCookies.map((c) => c.name.toLowerCase());
|
||||
|
||||
// Check for irsso_members
|
||||
|
||||
const hasIrssoMembers = cookieNames.some((name) =>
|
||||
name.includes('irsso_members') || name.includes('irsso')
|
||||
);
|
||||
|
||||
// Check for authtoken_members
|
||||
|
||||
const hasAuthtokenMembers = cookieNames.some((name) =>
|
||||
name.includes('authtoken_members') || name.includes('authtoken')
|
||||
);
|
||||
|
||||
|
||||
if (!hasIrssoMembers) {
|
||||
return Result.err('Required cookie missing: irsso_members');
|
||||
return Result.err<Cookie[]>(new Error('Required cookie missing: irsso_members'));
|
||||
}
|
||||
|
||||
|
||||
if (!hasAuthtokenMembers) {
|
||||
return Result.err('Required cookie missing: authtoken_members');
|
||||
return Result.err<Cookie[]>(new Error('Required cookie missing: authtoken_members'));
|
||||
}
|
||||
}
|
||||
|
||||
return Result.ok(validatedCookies);
|
||||
return Result.ok<Cookie[]>(validatedCookies);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Result.err(`Cookie validation failed: ${message}`);
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
return Result.err<Cookie[]>(new Error(`Cookie validation failed: ${err.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Browser, Page, BrowserContext } from 'playwright';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { extractDom, ExportedElement } from '../../../../scripts/dom-export/domExtractor';
|
||||
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
|
||||
import { BrowserAuthenticationState } from '../../../../domain/value-objects/BrowserAuthenticationState';
|
||||
@@ -775,6 +774,30 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
||||
}
|
||||
|
||||
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult> {
|
||||
const stepNumber = stepId.value;
|
||||
|
||||
if (!this.isRealMode() && this.config.baseUrl) {
|
||||
if (stepNumber >= 2 && stepNumber <= this.totalSteps) {
|
||||
try {
|
||||
const fixture = getFixtureForStep(stepNumber);
|
||||
if (fixture) {
|
||||
const base = this.config.baseUrl.replace(/\/$/, '');
|
||||
const url = `${base}/${fixture}`;
|
||||
this.log('debug', 'Mock mode: navigating to fixture for step', {
|
||||
step: stepNumber,
|
||||
url,
|
||||
});
|
||||
await this.navigator.navigateToPage(url);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log('debug', 'Mock mode fixture navigation failed (non-fatal)', {
|
||||
step: stepNumber,
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.stepOrchestrator.executeStep(stepId, config);
|
||||
}
|
||||
|
||||
@@ -888,36 +911,34 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
||||
*
|
||||
* Error dumps are always kept and not subject to cleanup.
|
||||
*/
|
||||
private async saveDebugInfo(stepName: string, error: Error): Promise<{ screenshotPath?: string; htmlPath?: string; domPath?: string }> {
|
||||
private async saveDebugInfo(stepName: string, error: Error): Promise<{ screenshotPath?: string; htmlPath?: string }> {
|
||||
if (!this.page) return {};
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const baseName = `debug-error-${stepName}-${timestamp}`;
|
||||
const debugDir = path.join(process.cwd(), 'debug-screenshots');
|
||||
const result: { screenshotPath?: string; htmlPath?: string; domPath?: string } = {};
|
||||
const result: { screenshotPath?: string; htmlPath?: string } = {};
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(debugDir, { recursive: true });
|
||||
|
||||
// Save screenshot
|
||||
const screenshotPath = path.join(debugDir, `${baseName}.png`);
|
||||
await this.page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
result.screenshotPath = screenshotPath;
|
||||
this.log('error', `Error debug screenshot saved: ${screenshotPath}`, { path: screenshotPath, error: error.message });
|
||||
this.log('error', `Error debug screenshot saved: ${screenshotPath}`, {
|
||||
path: screenshotPath,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
// Save HTML (cleaned to remove noise)
|
||||
const htmlPath = path.join(debugDir, `${baseName}.html`);
|
||||
const html = await this.page.evaluate(() => {
|
||||
// Clone the document
|
||||
const root = document.documentElement.cloneNode(true) as HTMLElement;
|
||||
|
||||
// Remove noise elements
|
||||
['script', 'noscript', 'meta', 'base', 'style', 'link', 'iframe',
|
||||
'picture', 'source', 'svg', 'path', 'img', 'canvas', 'video', 'audio']
|
||||
.forEach(sel => root.querySelectorAll(sel).forEach(n => n.remove()));
|
||||
.forEach((sel) => root.querySelectorAll(sel).forEach((n) => n.remove()));
|
||||
|
||||
// Remove empty non-interactive elements
|
||||
root.querySelectorAll('*').forEach(n => {
|
||||
root.querySelectorAll('*').forEach((n) => {
|
||||
const text = (n.textContent || '').trim();
|
||||
const interactive = n.matches('a,button,input,select,textarea,option,label');
|
||||
if (!interactive && text === '' && n.children.length === 0) {
|
||||
@@ -930,22 +951,6 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
||||
await fs.promises.writeFile(htmlPath, html);
|
||||
result.htmlPath = htmlPath;
|
||||
this.log('error', `Error debug HTML saved: ${htmlPath}`, { path: htmlPath });
|
||||
|
||||
// Save structural DOM export alongside HTML
|
||||
try {
|
||||
await this.page.evaluate(() => {
|
||||
(window as any).__name = (window as any).__name || ((fn: any) => fn);
|
||||
});
|
||||
const items = (await this.page.evaluate(
|
||||
extractDom as () => ExportedElement[]
|
||||
)) as unknown as ExportedElement[];
|
||||
const domPath = path.join(debugDir, `${baseName}.dom.json`);
|
||||
await fs.promises.writeFile(domPath, JSON.stringify(items, null, 2), 'utf8');
|
||||
result.domPath = domPath;
|
||||
this.log('error', `Error debug DOM saved: ${domPath}`, { path: domPath });
|
||||
} catch (domErr) {
|
||||
this.log('warn', 'Failed to save error debug DOM', { error: String(domErr) });
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('warn', 'Failed to save error debug info', { error: String(e) });
|
||||
}
|
||||
@@ -960,39 +965,36 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
||||
* Files are named with "before-step-N" prefix and old snapshots are cleaned up
|
||||
* to avoid disk bloat (keeps only last MAX_BEFORE_SNAPSHOTS).
|
||||
*/
|
||||
private async saveProactiveDebugInfo(step: number): Promise<{ screenshotPath?: string; htmlPath?: string; domPath?: string }> {
|
||||
private async saveProactiveDebugInfo(step: number): Promise<{ screenshotPath?: string; htmlPath?: string }> {
|
||||
if (!this.page) return {};
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const baseName = `debug-before-step-${step}-${timestamp}`;
|
||||
const debugDir = path.join(process.cwd(), 'debug-screenshots');
|
||||
const result: { screenshotPath?: string; htmlPath?: string; domPath?: string } = {};
|
||||
const result: { screenshotPath?: string; htmlPath?: string } = {};
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(debugDir, { recursive: true });
|
||||
|
||||
// Clean up old "before" snapshots first
|
||||
await this.cleanupOldBeforeSnapshots(debugDir);
|
||||
|
||||
// Save screenshot
|
||||
const screenshotPath = path.join(debugDir, `${baseName}.png`);
|
||||
await this.page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
result.screenshotPath = screenshotPath;
|
||||
this.log('info', `Pre-step screenshot saved: ${screenshotPath}`, { path: screenshotPath, step });
|
||||
this.log('info', `Pre-step screenshot saved: ${screenshotPath}`, {
|
||||
path: screenshotPath,
|
||||
step,
|
||||
});
|
||||
|
||||
// Save HTML (cleaned to remove noise)
|
||||
const htmlPath = path.join(debugDir, `${baseName}.html`);
|
||||
const html = await this.page.evaluate(() => {
|
||||
// Clone the document
|
||||
const root = document.documentElement.cloneNode(true) as HTMLElement;
|
||||
|
||||
// Remove noise elements
|
||||
['script', 'noscript', 'meta', 'base', 'style', 'link', 'iframe',
|
||||
'picture', 'source', 'svg', 'path', 'img', 'canvas', 'video', 'audio']
|
||||
.forEach(sel => root.querySelectorAll(sel).forEach(n => n.remove()));
|
||||
.forEach((sel) => root.querySelectorAll(sel).forEach((n) => n.remove()));
|
||||
|
||||
// Remove empty non-interactive elements
|
||||
root.querySelectorAll('*').forEach(n => {
|
||||
root.querySelectorAll('*').forEach((n) => {
|
||||
const text = (n.textContent || '').trim();
|
||||
const interactive = n.matches('a,button,input,select,textarea,option,label');
|
||||
if (!interactive && text === '' && n.children.length === 0) {
|
||||
@@ -1005,24 +1007,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
|
||||
await fs.promises.writeFile(htmlPath, html);
|
||||
result.htmlPath = htmlPath;
|
||||
this.log('info', `Pre-step HTML saved: ${htmlPath}`, { path: htmlPath, step });
|
||||
|
||||
// Save structural DOM export alongside HTML
|
||||
try {
|
||||
await this.page.evaluate(() => {
|
||||
(window as any).__name = (window as any).__name || ((fn: any) => fn);
|
||||
});
|
||||
const items = (await this.page.evaluate(
|
||||
extractDom as () => ExportedElement[]
|
||||
)) as unknown as ExportedElement[];
|
||||
const domPath = path.join(debugDir, `${baseName}.dom.json`);
|
||||
await fs.promises.writeFile(domPath, JSON.stringify(items, null, 2), 'utf8');
|
||||
result.domPath = domPath;
|
||||
this.log('info', `Pre-step DOM saved: ${domPath}`, { path: domPath, step });
|
||||
} catch (domErr) {
|
||||
this.log('warn', 'Failed to save proactive debug DOM', { error: String(domErr), step });
|
||||
}
|
||||
} catch (e) {
|
||||
// Don't fail step execution if debug save fails
|
||||
this.log('warn', 'Failed to save proactive debug info', { error: String(e), step });
|
||||
}
|
||||
|
||||
|
||||
@@ -307,13 +307,8 @@ export class WizardStepOrchestrator {
|
||||
break;
|
||||
|
||||
case 2:
|
||||
if (!this.isRealMode() && this.config.baseUrl) {
|
||||
const fixture = getFixtureForStep(2);
|
||||
if (fixture) {
|
||||
const base = this.config.baseUrl.replace(/\/$/, '');
|
||||
await this.navigator.navigateToPage(`${base}/${fixture}`);
|
||||
break;
|
||||
}
|
||||
if (!this.isRealMode()) {
|
||||
break;
|
||||
}
|
||||
await this.clickAction('create');
|
||||
break;
|
||||
@@ -351,11 +346,12 @@ export class WizardStepOrchestrator {
|
||||
'Race Information panel not found with fallback selector, dumping #create-race-wizard innerHTML',
|
||||
{ selector: raceInfoFallback },
|
||||
);
|
||||
const inner = await this.page!.evaluate(
|
||||
() =>
|
||||
document.querySelector('#create-race-wizard')?.innerHTML ||
|
||||
'',
|
||||
);
|
||||
const inner = await this.page!.evaluate(() => {
|
||||
const doc = (globalThis as any).document as any;
|
||||
return (
|
||||
doc?.querySelector('#create-race-wizard')?.innerHTML || ''
|
||||
);
|
||||
});
|
||||
this.log(
|
||||
'debug',
|
||||
'create-race-wizard innerHTML (truncated)',
|
||||
@@ -412,14 +408,57 @@ export class WizardStepOrchestrator {
|
||||
if (this.isRealMode()) {
|
||||
await this.waitForWizardStep('admins');
|
||||
await this.checkWizardDismissed(step);
|
||||
} else {
|
||||
const adminSearch =
|
||||
(config.adminSearch ?? config.admin) as string | undefined;
|
||||
if (adminSearch) {
|
||||
await this.fillField('adminSearch', String(adminSearch));
|
||||
}
|
||||
}
|
||||
await this.clickNextButton('Time Limit');
|
||||
break;
|
||||
|
||||
|
||||
case 6:
|
||||
if (this.isRealMode()) {
|
||||
await this.waitForWizardStep('admins');
|
||||
await this.checkWizardDismissed(step);
|
||||
} else {
|
||||
const adminSearch =
|
||||
(config.adminSearch ?? config.admin) as string | undefined;
|
||||
if (adminSearch) {
|
||||
await this.fillField('adminSearch', String(adminSearch));
|
||||
const page = this.page;
|
||||
if (page) {
|
||||
await page.evaluate((term) => {
|
||||
const doc = (globalThis as any).document as any;
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
const root =
|
||||
(doc.querySelector('#set-admins') as any) ?? doc.body;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
const rows = Array.from(
|
||||
(root as any).querySelectorAll(
|
||||
'tbody[data-testid="admin-display-name-list"] tr',
|
||||
),
|
||||
) as any[];
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
const needle = String(term).toLowerCase();
|
||||
for (const r of rows) {
|
||||
const text = String((r as any).textContent || '').toLowerCase();
|
||||
if (text.includes(needle)) {
|
||||
(r as any).setAttribute('data-selected-admin', 'true');
|
||||
return;
|
||||
}
|
||||
}
|
||||
(rows[0] as any).setAttribute('data-selected-admin', 'true');
|
||||
}, String(adminSearch));
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.clickNextButton('Time Limit');
|
||||
break;
|
||||
@@ -553,11 +592,11 @@ export class WizardStepOrchestrator {
|
||||
|
||||
case 9:
|
||||
this.log('info', 'Step 9: Validating we are still on Cars page');
|
||||
|
||||
|
||||
if (this.isRealMode()) {
|
||||
const actualPage = await this.detectCurrentWizardPage();
|
||||
const skipOffset = this.synchronizeStepCounter(step, actualPage);
|
||||
|
||||
|
||||
if (skipOffset > 0) {
|
||||
this.log('info', `Step ${step} was auto-skipped by wizard`, {
|
||||
actualPage,
|
||||
@@ -565,7 +604,7 @@ export class WizardStepOrchestrator {
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
const wizardFooter = await this.page!
|
||||
.locator('.wizard-footer')
|
||||
.innerText()
|
||||
@@ -573,21 +612,21 @@ export class WizardStepOrchestrator {
|
||||
this.log('info', 'Step 9: Current wizard footer', {
|
||||
footer: wizardFooter,
|
||||
});
|
||||
|
||||
|
||||
const onTrackPage =
|
||||
wizardFooter.includes('Track Options') ||
|
||||
(await this.page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.track)
|
||||
.isVisible()
|
||||
.catch(() => false));
|
||||
|
||||
|
||||
if (onTrackPage) {
|
||||
const errorMsg = `FATAL: Step 9 attempted on Track page (Step 11) - navigation bug detected. Wizard footer: "${wizardFooter}"`;
|
||||
this.log('error', errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const validation = await this.validatePageState({
|
||||
expectedStep: 'cars',
|
||||
requiredSelectors: this.isRealMode()
|
||||
@@ -595,7 +634,7 @@ export class WizardStepOrchestrator {
|
||||
: ['#set-cars, .wizard-step[id*="cars"], .cars-panel'],
|
||||
forbiddenSelectors: ['#set-track'],
|
||||
});
|
||||
|
||||
|
||||
if (validation.isErr()) {
|
||||
const errorMsg = `Step 9 validation error: ${
|
||||
validation.error?.message ?? 'unknown error'
|
||||
@@ -603,7 +642,7 @@ export class WizardStepOrchestrator {
|
||||
this.log('error', errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
|
||||
const validationResult = validation.unwrap();
|
||||
this.log('info', 'Step 9 validation result', {
|
||||
isValid: validationResult.isValid,
|
||||
@@ -611,7 +650,7 @@ export class WizardStepOrchestrator {
|
||||
missingSelectors: validationResult.missingSelectors,
|
||||
unexpectedSelectors: validationResult.unexpectedSelectors,
|
||||
});
|
||||
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
const errorMsg = `Step 9 FAILED validation: ${
|
||||
validationResult.message
|
||||
@@ -626,14 +665,16 @@ export class WizardStepOrchestrator {
|
||||
});
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
|
||||
this.log('info', 'Step 9 validation passed - confirmed on Cars page');
|
||||
|
||||
|
||||
const carIds = config.carIds as string[] | undefined;
|
||||
const carSearchTerm =
|
||||
(config.carSearch as string | undefined) ||
|
||||
(config.car as string | undefined) ||
|
||||
carIds?.[0];
|
||||
|
||||
if (this.isRealMode()) {
|
||||
const carIds = config.carIds as string[] | undefined;
|
||||
const carSearchTerm =
|
||||
config.carSearch || config.car || carIds?.[0];
|
||||
|
||||
if (carSearchTerm) {
|
||||
await this.clickAddCarButton();
|
||||
await this.waitForAddCarModal();
|
||||
@@ -647,11 +688,31 @@ export class WizardStepOrchestrator {
|
||||
|
||||
await this.clickNextButton('Car Classes');
|
||||
} else {
|
||||
if (config.carSearch) {
|
||||
await this.fillField('carSearch', String(config.carSearch));
|
||||
await this.clickAction('confirm');
|
||||
if (carSearchTerm) {
|
||||
const page = this.page;
|
||||
if (page) {
|
||||
await this.clickAddCarButton();
|
||||
await this.waitForAddCarModal();
|
||||
await this.fillField('carSearch', String(carSearchTerm));
|
||||
await page.waitForTimeout(200);
|
||||
try {
|
||||
await this.selectFirstSearchResult();
|
||||
} catch (e) {
|
||||
this.log('debug', 'Step 9 mock mode: selectFirstSearchResult failed (non-fatal)', {
|
||||
error: String(e),
|
||||
});
|
||||
}
|
||||
|
||||
this.log('info', 'Step 9 mock mode: selected car from JSON-backed list', {
|
||||
carSearch: String(carSearchTerm),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.log(
|
||||
'debug',
|
||||
'Step 9 mock mode: no carSearch provided, skipping car addition',
|
||||
);
|
||||
}
|
||||
await this.clickNextButton('Car Classes');
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -805,13 +866,23 @@ export class WizardStepOrchestrator {
|
||||
|
||||
await this.waitForWizardStep('timeOfDay');
|
||||
await this.checkWizardDismissed(step);
|
||||
}
|
||||
if (config.trackConfig) {
|
||||
await this.selectDropdown('trackConfig', String(config.trackConfig));
|
||||
}
|
||||
await this.clickNextButton('Time of Day');
|
||||
if (this.isRealMode()) {
|
||||
|
||||
if (config.timeOfDay !== undefined) {
|
||||
await this.setSlider('timeOfDay', Number(config.timeOfDay));
|
||||
}
|
||||
if (config.trackConfig) {
|
||||
await this.selectDropdown('trackConfig', String(config.trackConfig));
|
||||
}
|
||||
|
||||
await this.clickNextButton('Time of Day');
|
||||
await this.waitForWizardStep('timeOfDay');
|
||||
} else {
|
||||
if (config.timeOfDay !== undefined) {
|
||||
await this.setSlider('timeOfDay', Number(config.timeOfDay));
|
||||
}
|
||||
if (config.trackConfig) {
|
||||
await this.selectDropdown('trackConfig', String(config.trackConfig));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -863,11 +934,12 @@ export class WizardStepOrchestrator {
|
||||
'Weather panel not found with fallback selector, dumping #create-race-wizard innerHTML',
|
||||
{ selector: weatherFallbackSelector },
|
||||
);
|
||||
const inner = await this.page!.evaluate(
|
||||
() =>
|
||||
document.querySelector('#create-race-wizard')?.innerHTML ||
|
||||
'',
|
||||
);
|
||||
const inner = await this.page!.evaluate(() => {
|
||||
const doc = (globalThis as any).document as any;
|
||||
return (
|
||||
doc?.querySelector('#create-race-wizard')?.innerHTML || ''
|
||||
);
|
||||
});
|
||||
this.log(
|
||||
'debug',
|
||||
'create-race-wizard innerHTML (truncated)',
|
||||
@@ -889,14 +961,32 @@ export class WizardStepOrchestrator {
|
||||
});
|
||||
}
|
||||
await this.checkWizardDismissed(step);
|
||||
|
||||
if (config.timeOfDay !== undefined) {
|
||||
await this.setSlider('timeOfDay', Number(config.timeOfDay));
|
||||
}
|
||||
} else {
|
||||
if (config.weatherType) {
|
||||
await this.selectDropdown('weatherType', String(config.weatherType));
|
||||
}
|
||||
if (config.temperature !== undefined && this.page) {
|
||||
const tempSelector = IRACING_SELECTORS.steps.temperature;
|
||||
const tempExists =
|
||||
(await this.page
|
||||
.locator(tempSelector)
|
||||
.first()
|
||||
.count()
|
||||
.catch(() => 0)) > 0;
|
||||
if (tempExists) {
|
||||
await this.setSlider('temperature', Number(config.temperature));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (config.timeOfDay !== undefined) {
|
||||
await this.setSlider('timeOfDay', Number(config.timeOfDay));
|
||||
}
|
||||
|
||||
if (this.isRealMode()) {
|
||||
await this.dismissDatetimePickers();
|
||||
await this.clickNextButton('Weather');
|
||||
}
|
||||
await this.clickNextButton('Weather');
|
||||
break;
|
||||
|
||||
case 16:
|
||||
@@ -1000,6 +1090,10 @@ export class WizardStepOrchestrator {
|
||||
} else {
|
||||
const valueStr = String(config.trackState);
|
||||
await this.page!.evaluate((trackStateValue) => {
|
||||
const doc = (globalThis as any).document as any;
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
const map: Record<string, number> = {
|
||||
'very-low': 10,
|
||||
low: 25,
|
||||
@@ -1011,24 +1105,29 @@ export class WizardStepOrchestrator {
|
||||
};
|
||||
const numeric = map[trackStateValue] ?? null;
|
||||
const inputs = Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>(
|
||||
doc.querySelectorAll(
|
||||
'input[id*="starting-track-state"], input[id*="track-state"], input[data-value]',
|
||||
),
|
||||
);
|
||||
) as any[];
|
||||
if (numeric !== null && inputs.length > 0) {
|
||||
for (const inp of inputs) {
|
||||
try {
|
||||
inp.value = String(numeric);
|
||||
(inp as any).dataset = (inp as any).dataset || {};
|
||||
(inp as any).dataset.value = String(numeric);
|
||||
inp.setAttribute('data-value', String(numeric));
|
||||
inp.dispatchEvent(
|
||||
new Event('input', { bubbles: true }),
|
||||
(inp as any).value = String(numeric);
|
||||
const ds =
|
||||
(inp as any).dataset || ((inp as any).dataset = {});
|
||||
ds.value = String(numeric);
|
||||
(inp as any).setAttribute?.(
|
||||
'data-value',
|
||||
String(numeric),
|
||||
);
|
||||
inp.dispatchEvent(
|
||||
new Event('change', { bubbles: true }),
|
||||
const Ev = (globalThis as any).Event;
|
||||
(inp as any).dispatchEvent?.(
|
||||
new Ev('input', { bubbles: true }),
|
||||
);
|
||||
} catch (e) {
|
||||
(inp as any).dispatchEvent?.(
|
||||
new Ev('change', { bubbles: true }),
|
||||
);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +280,30 @@ export class IRacingDomInteractor {
|
||||
const page = this.getPage();
|
||||
|
||||
if (!this.isRealMode()) {
|
||||
const timeout = this.config.timeout;
|
||||
|
||||
try {
|
||||
const footerButtons = page.locator('.wizard-footer a.btn, .wizard-footer button');
|
||||
const count = await footerButtons.count().catch(() => 0);
|
||||
|
||||
if (count > 0) {
|
||||
const targetText = nextStepName.toLowerCase();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const button = footerButtons.nth(i);
|
||||
const text = (await button.innerText().catch(() => '')).trim().toLowerCase();
|
||||
if (text && text.includes(targetText)) {
|
||||
await button.click({ timeout, force: true });
|
||||
this.log('info', 'Clicked mock next button via footer text match', {
|
||||
nextStepName,
|
||||
text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
await this.clickAction('next');
|
||||
return;
|
||||
}
|
||||
@@ -346,7 +370,13 @@ export class IRacingDomInteractor {
|
||||
try {
|
||||
const count = await page.locator(h).first().count().catch(() => 0);
|
||||
if (count > 0) {
|
||||
const tag = await page.locator(h).first().evaluate((el) => el.tagName.toLowerCase()).catch(() => '');
|
||||
const tag = await page
|
||||
.locator(h)
|
||||
.first()
|
||||
.evaluate((el: any) =>
|
||||
String((el as any).tagName || '').toLowerCase(),
|
||||
)
|
||||
.catch(() => '');
|
||||
if (tag === 'select') {
|
||||
try {
|
||||
await page.selectOption(h, value);
|
||||
@@ -514,7 +544,11 @@ export class IRacingDomInteractor {
|
||||
const count = await locator.count().catch(() => 0);
|
||||
if (count === 0) continue;
|
||||
|
||||
const tagName = await locator.evaluate((el) => el.tagName.toLowerCase()).catch(() => '');
|
||||
const tagName = await locator
|
||||
.evaluate((el: any) =>
|
||||
String((el as any).tagName || '').toLowerCase(),
|
||||
)
|
||||
.catch(() => '');
|
||||
const type = await locator.getAttribute('type').catch(() => '');
|
||||
|
||||
if (tagName === 'input' && (type === 'checkbox' || type === 'radio')) {
|
||||
@@ -648,7 +682,11 @@ export class IRacingDomInteractor {
|
||||
const count = await locator.count().catch(() => 0);
|
||||
if (count === 0) continue;
|
||||
|
||||
const tagName = await locator.evaluate((el) => el.tagName.toLowerCase()).catch(() => '');
|
||||
const tagName = await locator
|
||||
.evaluate((el: any) =>
|
||||
String((el as any).tagName || '').toLowerCase(),
|
||||
)
|
||||
.catch(() => '');
|
||||
if (tagName === 'input') {
|
||||
const type = await locator.getAttribute('type').catch(() => '');
|
||||
if (type === 'range' || type === 'text' || type === 'number') {
|
||||
@@ -746,7 +784,7 @@ export class IRacingDomInteractor {
|
||||
|
||||
const addCarButtonSelector = this.isRealMode()
|
||||
? IRACING_SELECTORS.steps.addCarButton
|
||||
: '[data-action="add-car"]';
|
||||
: `${IRACING_SELECTORS.steps.addCarButton}, [data-action="add-car"]`;
|
||||
|
||||
try {
|
||||
this.log('info', 'Clicking Add Car button to open modal');
|
||||
|
||||
@@ -97,20 +97,28 @@ export const IRACING_SELECTORS = {
|
||||
leagueRacingToggle: '#set-session-information .switch-checkbox, [data-toggle="leagueRacing"]',
|
||||
|
||||
// Step 4: Server Details
|
||||
region: '#set-server-details select.form-control, #set-server-details [data-dropdown="region"], #set-server-details [data-dropdown], [data-dropdown="region"]',
|
||||
startNow: '#set-server-details .switch-checkbox, #set-server-details input[type="checkbox"], [data-toggle="startNow"], input[data-toggle="startNow"]',
|
||||
|
||||
region:
|
||||
'#set-server-details select.form-control, ' +
|
||||
'#set-server-details [data-dropdown="region"], ' +
|
||||
'#set-server-details [data-dropdown], ' +
|
||||
'[data-dropdown="region"], ' +
|
||||
'#set-server-details [role="radiogroup"] input[type="radio"]',
|
||||
startNow:
|
||||
'#set-server-details .switch-checkbox, ' +
|
||||
'#set-server-details input[type="checkbox"], ' +
|
||||
'[data-toggle="startNow"], ' +
|
||||
'input[data-toggle="startNow"]',
|
||||
|
||||
// Step 5/6: Admins
|
||||
adminSearch: 'input[placeholder*="Search"]',
|
||||
adminList: '#set-admins table.table.table-striped, #set-admins .card-block table',
|
||||
addAdminButton: 'a.btn:has-text("Add an Admin")',
|
||||
|
||||
// Step 7: Time Limits - Bootstrap-slider uses hidden input[type="text"] with id containing slider name
|
||||
// Also targets the visible slider handle for interaction
|
||||
// Dumps show dynamic IDs like time-limit-slider1763726367635
|
||||
practice: 'label:has-text("Practice") ~ div input[id*="time-limit-slider"]',
|
||||
qualify: 'label:has-text("Qualify") ~ div input[id*="time-limit-slider"]',
|
||||
race: 'label:has-text("Race") ~ div input[id*="time-limit-slider"]',
|
||||
// Step 7: Time Limits - Bootstrap-slider uses hidden input[type="text"] with dynamic id
|
||||
// Fixtures show ids like time-limit-slider1764248520320
|
||||
practice: '#set-time-limit input[id*="time-limit-slider"]',
|
||||
qualify: '#set-time-limit input[id*="time-limit-slider"]',
|
||||
race: '#set-time-limit input[id*="time-limit-slider"]',
|
||||
|
||||
// Step 8/9: Cars
|
||||
carSearch: 'input[placeholder*="Search"]',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { 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';
|
||||
|
||||
/**
|
||||
@@ -14,12 +14,13 @@ import { getStepName } from './templates/IRacingTemplateMap';
|
||||
* 3. Managing session state transitions
|
||||
*
|
||||
* This is a REAL implementation that uses actual automation,
|
||||
* not a mock. Currently delegates to deprecated nut.js adapters for
|
||||
* screen automation operations.
|
||||
* not a mock. Historically delegated to legacy native screen
|
||||
* automation adapters, but those are no longer part of the
|
||||
* supported stack.
|
||||
*
|
||||
* @deprecated This adapter currently delegates to the deprecated NutJsAutomationAdapter.
|
||||
* Should be updated to use Playwright browser automation when available.
|
||||
* See docs/ARCHITECTURE.md for the updated automation strategy.
|
||||
* @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;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { 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 {
|
||||
@@ -68,9 +68,14 @@ 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 unknown 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}`;
|
||||
const errorMessage = `Step ${currentStep.value} (${getStepName(
|
||||
currentStep.value,
|
||||
)}) failed: ${result.error}`;
|
||||
console.error(errorMessage);
|
||||
|
||||
// Stop automation and mark session as failed
|
||||
@@ -95,9 +100,14 @@ export class MockAutomationEngineAdapter implements IAutomationEngine {
|
||||
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>);
|
||||
const result = await this.browserAutomation.executeStep(
|
||||
nextStep,
|
||||
config as unknown as Record<string, unknown>,
|
||||
);
|
||||
if (!result.success) {
|
||||
const errorMessage = `Step ${nextStep.value} (${getStepName(nextStep.value)}) failed: ${result.error}`;
|
||||
const errorMessage = `Step ${nextStep.value} (${getStepName(
|
||||
nextStep.value,
|
||||
)}) 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()
|
||||
@@ -118,6 +128,19 @@ export class MockAutomationEngineAdapter implements IAutomationEngine {
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { StepId } from '../../../domain/value-objects/StepId';
|
||||
import { HostedSessionConfig } from '../../../domain/entities/HostedSessionConfig';
|
||||
import type { IBrowserAutomation } from '../../../application/ports/IScreenAutomation';
|
||||
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||
import type { IBrowserAutomation } from '../../../../application/ports/IScreenAutomation';
|
||||
import {
|
||||
NavigationResult,
|
||||
FormFillResult,
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
WaitResult,
|
||||
ModalResult,
|
||||
AutomationResult,
|
||||
} from '../../../application/ports/AutomationResults';
|
||||
} from '../../../../application/ports/AutomationResults';
|
||||
|
||||
interface MockConfig {
|
||||
simulateFailures?: boolean;
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* allowing switching between different adapters based on NODE_ENV.
|
||||
*
|
||||
* Mapping:
|
||||
* - NODE_ENV=production → NutJsAutomationAdapter → iRacing Window → Image Templates
|
||||
* - NODE_ENV=development → NutJsAutomationAdapter → iRacing Window → Image Templates
|
||||
* - NODE_ENV=production → real browser automation → iRacing Window → Image Templates
|
||||
* - NODE_ENV=development → real browser automation → iRacing Window → Image Templates
|
||||
* - NODE_ENV=test → MockBrowserAutomation → N/A → N/A
|
||||
*/
|
||||
|
||||
@@ -68,7 +68,7 @@ export const DEFAULT_TIMING_CONFIG: TimingConfig = {
|
||||
export interface AutomationEnvironmentConfig {
|
||||
mode: AutomationMode;
|
||||
|
||||
/** Production mode configuration (nut.js) */
|
||||
/** Production/development configuration for native automation */
|
||||
nutJs?: {
|
||||
mouseSpeed?: number;
|
||||
keyboardDelay?: number;
|
||||
@@ -124,7 +124,7 @@ export function getAutomationMode(): AutomationMode {
|
||||
* Environment variables:
|
||||
* - NODE_ENV: 'production' | 'test' (default: 'test')
|
||||
* - AUTOMATION_MODE: (deprecated) 'dev' | 'production' | 'mock'
|
||||
* - IRACING_WINDOW_TITLE: Window title for nut.js (default: 'iRacing')
|
||||
* - IRACING_WINDOW_TITLE: Window title for native automation (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)
|
||||
|
||||
Reference in New Issue
Block a user