This commit is contained in:
2025-12-01 17:27:56 +01:00
parent e7ada8aa23
commit 98a09a3f2b
41 changed files with 2341 additions and 1525 deletions

View File

@@ -565,56 +565,40 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
}
try {
// Create a function that checks if selectors exist on the page
const checkSelector = (selector: string): boolean => {
// Synchronously check if selector exists (count > 0)
// We'll need to make this sync-compatible, so we check in the validator call
return false; // Placeholder - will be resolved in evaluate
};
const selectorChecks: Record<string, boolean> = {};
// Use page.evaluate to check all selectors at once in the browser context
const selectorChecks = await this.page.evaluate(
({ requiredSelectors, forbiddenSelectors }) => {
const results: Record<string, boolean> = {};
// Check required selectors
for (const selector of requiredSelectors) {
try {
results[selector] = document.querySelectorAll(selector).length > 0;
} catch {
results[selector] = false;
}
}
// Check forbidden selectors
for (const selector of forbiddenSelectors || []) {
try {
results[selector] = document.querySelectorAll(selector).length > 0;
} catch {
results[selector] = false;
}
}
return results;
},
{
requiredSelectors: validation.requiredSelectors,
forbiddenSelectors: validation.forbiddenSelectors || []
for (const selector of validation.requiredSelectors) {
try {
const count = await this.page.locator(selector).count();
selectorChecks[selector] = count > 0;
} catch {
selectorChecks[selector] = false;
}
);
}
for (const selector of validation.forbiddenSelectors || []) {
try {
const count = await this.page.locator(selector).count();
selectorChecks[selector] = count > 0;
} catch {
selectorChecks[selector] = false;
}
}
// Create actualState function that uses the captured results
const actualState = (selector: string): boolean => {
return selectorChecks[selector] === true;
};
// Validate using domain service
return this.pageStateValidator.validateStateEnhanced(actualState, validation, this.isRealMode());
return this.pageStateValidator.validateStateEnhanced(
actualState,
validation,
this.isRealMode(),
);
} catch (error) {
return Result.err(
error instanceof Error
? error
: new Error(`Page state validation failed: ${String(error)}`)
: new Error(`Page state validation failed: ${String(error)}`),
);
}
}
@@ -775,29 +759,53 @@ 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', {
const skipFixtureNavigation =
(config as any).__skipFixtureNavigation === true;
if (!skipFixtureNavigation) {
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,
url,
error: String(error),
});
}
}
} else if (this.isRealMode() && this.config.baseUrl && !this.config.baseUrl.includes('members.iracing.com')) {
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('info', 'Fixture host (real mode): navigating to fixture for step', {
step: stepNumber,
url,
});
await this.navigator.navigateToPage(url);
}
} catch (error) {
this.log('warn', 'Real-mode fixture navigation failed (non-fatal)', {
step: stepNumber,
error: String(error),
});
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);
}
@@ -1852,8 +1860,10 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
}
/**
* Click the "New Race" button in the modal that appears after clicking "Create a Race".
* This modal asks whether to use "Last Settings" or "New Race".
* Click the "New Race" option in the modal that appears after clicking "Create a Race".
* Supports both:
* - Direct "New Race" button
* - Dropdown menu with "Last Settings" / "New Race" items (fixture HTML)
*/
private async clickNewRaceInModal(): Promise<void> {
if (!this.page) {
@@ -1863,26 +1873,58 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
try {
this.log('info', 'Waiting for Create Race modal to appear');
// Wait for the modal - use 'attached' because iRacing elements may have class="hidden"
const modalSelector = IRACING_SELECTORS.hostedRacing.createRaceModal;
await this.page.waitForSelector(modalSelector, {
state: 'attached',
timeout: IRACING_TIMEOUTS.elementWait,
});
this.log('info', 'Create Race modal attached, clicking New Race button');
this.log('info', 'Create Race modal attached, resolving New Race control');
// Click the "New Race" button - use 'attached' for consistency
const newRaceSelector = IRACING_SELECTORS.hostedRacing.newRaceButton;
await this.page.waitForSelector(newRaceSelector, {
state: 'attached',
timeout: IRACING_TIMEOUTS.elementWait,
});
await this.safeClick(newRaceSelector, { timeout: IRACING_TIMEOUTS.elementWait });
const directSelector = IRACING_SELECTORS.hostedRacing.newRaceButton;
const direct = this.page.locator(directSelector).first();
const hasDirect =
(await direct.count().catch(() => 0)) > 0 &&
(await direct.isVisible().catch(() => false));
this.log('info', 'Clicked New Race button, waiting for form to load');
if (hasDirect) {
this.log('info', 'Clicking direct New Race button', { selector: directSelector });
await this.safeClick(directSelector, { timeout: IRACING_TIMEOUTS.elementWait });
} else {
const dropdownToggleSelector =
'.btn-toolbar .btn-group.dropup > a.dropdown-toggle, .btn-group.dropup > a.dropdown-toggle';
const dropdownToggle = this.page.locator(dropdownToggleSelector).first();
const hasDropdown =
(await dropdownToggle.count().catch(() => 0)) > 0 &&
(await dropdownToggle.isVisible().catch(() => false));
// Wait a moment for the form to load
if (!hasDropdown) {
throw new Error(
`Create Race modal present but no direct New Race button or dropdown toggle found (selectors: ${directSelector}, ${dropdownToggleSelector})`,
);
}
this.log('info', 'Clicking dropdown toggle to open New Race menu', {
selector: dropdownToggleSelector,
});
await this.safeClick(dropdownToggleSelector, {
timeout: IRACING_TIMEOUTS.elementWait,
});
const menuSelector =
'.dropdown-menu a.dropdown-item.text-danger:has-text("New Race"), .dropdown-menu a.dropdown-item:has-text("New Race")';
this.log('debug', 'Waiting for New Race entry in dropdown menu', {
selector: menuSelector,
});
await this.page.waitForSelector(menuSelector, {
state: 'attached',
timeout: IRACING_TIMEOUTS.elementWait,
});
await this.safeClick(menuSelector, { timeout: IRACING_TIMEOUTS.elementWait });
this.log('info', 'Clicked New Race dropdown item');
}
this.log('info', 'Waiting for Race Information form to load after New Race selection');
await this.page.waitForTimeout(500);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -1949,7 +1991,13 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, IAuthent
*/
private async handleLogin(): Promise<AutomationResult> {
try {
// Check session cookies FIRST before launching browser
if (this.config.baseUrl && !this.config.baseUrl.includes('members.iracing.com')) {
this.log('info', 'Fixture baseUrl detected, treating session as authenticated for Step 1', {
baseUrl: this.config.baseUrl,
});
return { success: true };
}
const sessionResult = await this.checkSession();
if (

View File

@@ -486,6 +486,11 @@ export class WizardStepOrchestrator {
const skipOffset = this.synchronizeStepCounter(step, actualPage);
if (skipOffset > 0) {
if (this.config.baseUrl) {
const errorMsg = `Step 8 FAILED validation: Wizard auto-skip detected (expected "cars" but on "${actualPage}")`;
this.log('error', errorMsg, { actualPage, skipOffset });
throw new Error(errorMsg);
}
this.log('info', `Step ${step} was auto-skipped by wizard`, {
actualPage,
skipOffset,
@@ -557,9 +562,11 @@ export class WizardStepOrchestrator {
const step8Validation = await this.validatePageState({
expectedStep: 'cars',
requiredSelectors: this.isRealMode()
? [IRACING_SELECTORS.steps.addCarButton]
? [IRACING_SELECTORS.wizard.stepContainers.cars]
: ['#set-cars, .wizard-step[id*="cars"], .cars-panel'],
forbiddenSelectors: ['#set-track'],
forbiddenSelectors: [
IRACING_SELECTORS.wizard.stepContainers.track,
],
});
if (step8Validation.isErr()) {
@@ -592,19 +599,24 @@ 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) {
if (this.config.baseUrl) {
const errorMsg = `Step 9 FAILED validation: Wizard auto-skip detected (expected "cars" but on "${actualPage}")`;
this.log('error', errorMsg, { actualPage, skipOffset });
throw new Error(errorMsg);
}
this.log('info', `Step ${step} was auto-skipped by wizard`, {
actualPage,
skipOffset,
});
return { success: true };
}
const wizardFooter = await this.page!
.locator('.wizard-footer')
.innerText()
@@ -612,37 +624,42 @@ 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}"`;
const errorMsg = `Step 9 FAILED validation: Wizard footer indicates Track page while executing Cars-add-car step. Wizard footer: "${wizardFooter}"`;
this.log('error', errorMsg);
throw new Error(errorMsg);
}
}
const validation = await this.validatePageState({
expectedStep: 'cars',
requiredSelectors: this.isRealMode()
? [IRACING_SELECTORS.steps.addCarButton]
? [
IRACING_SELECTORS.wizard.stepContainers.cars,
IRACING_SELECTORS.steps.addCarButton,
]
: ['#set-cars, .wizard-step[id*="cars"], .cars-panel'],
forbiddenSelectors: ['#set-track'],
forbiddenSelectors: [
IRACING_SELECTORS.wizard.stepContainers.track,
],
});
if (validation.isErr()) {
const errorMsg = `Step 9 validation error: ${
const errorMsg = `Step 9 FAILED validation: ${
validation.error?.message ?? 'unknown error'
}`;
this.log('error', errorMsg);
throw new Error(errorMsg);
}
const validationResult = validation.unwrap();
this.log('info', 'Step 9 validation result', {
isValid: validationResult.isValid,
@@ -650,12 +667,14 @@ export class WizardStepOrchestrator {
missingSelectors: validationResult.missingSelectors,
unexpectedSelectors: validationResult.unexpectedSelectors,
});
if (!validationResult.isValid) {
const errorMsg = `Step 9 FAILED validation: ${
validationResult.message
}. Browser is ${
validationResult.unexpectedSelectors?.includes('#set-track')
validationResult.unexpectedSelectors?.includes(
IRACING_SELECTORS.wizard.stepContainers.track,
)
? '3 steps ahead on Track page'
: 'on wrong page'
}`;
@@ -665,7 +684,7 @@ 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;
@@ -675,6 +694,18 @@ export class WizardStepOrchestrator {
carIds?.[0];
if (this.isRealMode()) {
const isFixtureHost =
this.config.baseUrl &&
!this.config.baseUrl.includes('members.iracing.com');
if (isFixtureHost) {
this.log('info', 'Step 9: fixture host detected, skipping Add Car interactions (DOM already has cars table)', {
baseUrl: this.config.baseUrl,
carSearchTerm,
});
return { success: true };
}
if (carSearchTerm) {
await this.clickAddCarButton();
await this.waitForAddCarModal();
@@ -685,7 +716,7 @@ export class WizardStepOrchestrator {
car: carSearchTerm,
});
}
await this.clickNextButton('Car Classes');
} else {
if (carSearchTerm) {
@@ -804,9 +835,20 @@ export class WizardStepOrchestrator {
await this.waitForWizardStep('trackOptions');
await this.checkWizardDismissed(step);
const isFixtureHost =
this.config.baseUrl &&
!this.config.baseUrl.includes('members.iracing.com');
const trackSearchTerm =
config.trackSearch || config.track || config.trackId;
if (trackSearchTerm) {
if (isFixtureHost) {
this.log(
'info',
'Step 13: fixture host detected, skipping Add Track interactions (track already present in fixture)',
{ baseUrl: this.config.baseUrl, trackSearchTerm },
);
} else if (trackSearchTerm) {
await this.clickAddTrackButton();
await this.waitForAddTrackModal();
await this.fillField('trackSearch', String(trackSearchTerm));