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 (