import type { Page } from 'playwright'; import type { ILogger } from '../../../../application/ports/ILogger'; import type { FormFillResult, ClickResult, ModalResult, } from '../../../../application/ports/AutomationResults'; import { StepId } from '../../../../domain/value-objects/StepId'; import type { PlaywrightConfig } from '../core/PlaywrightAutomationAdapter'; import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession'; import { IRACING_SELECTORS, IRACING_TIMEOUTS } from './IRacingSelectors'; import { SafeClickService } from './SafeClickService'; export class IRacingDomInteractor { constructor( private readonly config: Required, private readonly browserSession: PlaywrightBrowserSession, private readonly safeClickService: SafeClickService, private readonly logger?: ILogger, ) {} private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record): void { if (!this.logger) { return; } const logger: any = this.logger; logger[level](message, context as any); } private isRealMode(): boolean { return this.config.mode === 'real'; } private getPage(): Page { const page = this.browserSession.getPage(); if (!page) { throw new Error('Browser not connected'); } return page; } // ===== Public port-facing operations ===== async fillFormField(fieldName: string, value: string): Promise { const page = this.browserSession.getPage(); if (!page) { return { success: false, fieldName, valueSet: value, error: 'Browser not connected' }; } const fieldMap: Record = { sessionName: `${IRACING_SELECTORS.steps.sessionName}, ${IRACING_SELECTORS.steps.sessionNameAlt}`, password: `${IRACING_SELECTORS.steps.password}, ${IRACING_SELECTORS.steps.passwordAlt}`, description: `${IRACING_SELECTORS.steps.description}, ${IRACING_SELECTORS.steps.descriptionAlt}`, adminSearch: IRACING_SELECTORS.steps.adminSearch, carSearch: IRACING_SELECTORS.steps.carSearch, trackSearch: IRACING_SELECTORS.steps.trackSearch, maxDrivers: IRACING_SELECTORS.steps.maxDrivers, }; if (!Object.prototype.hasOwnProperty.call(fieldMap, fieldName)) { return { success: false, fieldName, valueSet: value, error: `Unknown form field: ${fieldName}` }; } const selector = fieldMap[fieldName]; const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; this.log('debug', 'Filling form field', { fieldName, selector, mode: this.config.mode }); try { await page.waitForSelector(selector, { state: 'attached', timeout }); try { await page.fill(selector, value); return { success: true, fieldName, valueSet: value }; } catch (fillErr) { if (this.isRealMode()) { throw fillErr; } try { await page.evaluate(({ sel, val }) => { document .querySelectorAll('.wizard-step.hidden, .modal.hidden, .wizard-step[hidden]') .forEach((el) => { el.classList.remove('hidden'); el.removeAttribute('hidden'); }); const el = document.querySelector(sel) as HTMLInputElement | HTMLTextAreaElement | null; if (!el) return; (el as any).value = val; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }, { sel: selector, val: value }); return { success: true, fieldName, valueSet: value }; } catch (evalErr) { const message = evalErr instanceof Error ? evalErr.message : String(evalErr); return { success: false, fieldName, valueSet: value, error: message }; } } } catch (error) { const message = error instanceof Error ? error.message : String(error); return { success: false, fieldName, valueSet: value, error: message }; } } async clickElement(target: string): Promise { const page = this.browserSession.getPage(); if (!page) { return { success: false, target, error: 'Browser not connected' }; } try { const selector = this.getActionSelector(target); const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; this.log('debug', 'Clicking element', { target, selector, mode: this.config.mode }); await page.waitForSelector(selector, { state: 'attached', timeout }); await page.click(selector); return { success: true, target }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { success: false, target, error: message }; } } async handleModal(stepId: StepId, action: string): Promise { const page = this.browserSession.getPage(); if (!page) { return { success: false, stepId: stepId.value, action, error: 'Browser not connected' }; } try { const modalSelector = IRACING_SELECTORS.wizard.modal; const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; this.log('debug', 'Handling modal', { action, mode: this.config.mode }); await page.waitForSelector(modalSelector, { state: 'attached', timeout }); let buttonSelector: string; if (action === 'confirm') { buttonSelector = IRACING_SELECTORS.wizard.confirmButton; } else if (action === 'cancel') { buttonSelector = IRACING_SELECTORS.wizard.cancelButton; } else { return { success: false, stepId: stepId.value, action, error: `Unknown modal action: ${action}` }; } await page.click(buttonSelector); return { success: true, stepId: stepId.value, action }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { success: false, stepId: stepId.value, action, error: message }; } } // ===== Public interaction helpers used by adapter steps ===== async fillField(fieldName: string, value: string): Promise { const page = this.browserSession.getPage(); if (!page) { return { success: false, fieldName, valueSet: value, error: 'Browser not connected' }; } const selector = this.getFieldSelector(fieldName); const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; this.log('debug', 'fillField', { fieldName, selector, mode: this.config.mode }); if (!this.isRealMode()) { try { await page.evaluate(() => { document .querySelectorAll('.wizard-step.hidden, .modal.hidden, .wizard-step[hidden]') .forEach((el) => { el.classList.remove('hidden'); el.removeAttribute('hidden'); }); }); } catch { // ignore } } await page.waitForSelector(selector, { state: 'attached', timeout }); try { await page.fill(selector, value); return { success: true, fieldName, valueSet: value }; } catch (fillErr) { if (this.isRealMode()) { const message = fillErr instanceof Error ? fillErr.message : String(fillErr); return { success: false, fieldName, valueSet: value, error: message }; } try { await page.evaluate(({ sel, val }) => { const el = document.querySelector(sel) as HTMLInputElement | HTMLTextAreaElement | null; if (!el) return; (el as any).value = val; (el as any).dispatchEvent(new Event('input', { bubbles: true })); (el as any).dispatchEvent(new Event('change', { bubbles: true })); }, { sel: selector, val: value }); return { success: true, fieldName, valueSet: value }; } catch (evalErr) { const message = evalErr instanceof Error ? evalErr.message : String(evalErr); return { success: false, fieldName, valueSet: value, error: message }; } } } async fillFieldWithFallback(fieldName: string, value: string): Promise { const page = this.browserSession.getPage(); if (!page) { return { success: false, fieldName, valueSet: value, error: 'Browser not connected' }; } const selector = this.getFieldSelector(fieldName); const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; const selectors = selector.split(', ').map((s) => s.trim()); for (const sel of selectors) { try { this.log('debug', `Trying selector for ${fieldName}`, { selector: sel }); const element = page.locator(sel).first(); const isVisible = await element.isVisible().catch(() => false); if (isVisible) { await element.waitFor({ state: 'attached', timeout }); await element.fill(value); this.log('info', `Successfully filled ${fieldName}`, { selector: sel, value }); return { success: true, fieldName, valueSet: value }; } } catch (error) { this.log('debug', `Selector failed for ${fieldName}`, { selector: sel, error: String(error) }); } } try { this.log('debug', `Trying combined selector for ${fieldName}`, { selector }); await page.waitForSelector(selector, { state: 'attached', timeout }); await page.fill(selector, value); return { success: true, fieldName, valueSet: value }; } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', `Failed to fill ${fieldName}`, { selector, error: message }); return { success: false, fieldName, valueSet: value, error: message }; } } async clickAction(action: string): Promise { const page = this.browserSession.getPage(); if (!page) { return { success: false, target: action, error: 'Browser not connected' }; } const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; let selector: string; if (!this.isRealMode()) { const mockMap: Record = { create: '#create-race-btn, [data-action="create"], button:has-text("Create a Race")', next: '.wizard-footer a.btn.btn-primary, .wizard-footer a:has(.icon-caret-right), [data-action="next"], button:has-text("Next")', back: '.wizard-footer a.btn.btn-secondary, .wizard-footer a:has(.icon-caret-left):has-text("Back"), [data-action="back"], button:has-text("Back")', confirm: '.modal-footer a.btn-success, button:has-text("Confirm"), [data-action="confirm"]', cancel: '.modal-footer a.btn-secondary, button:has-text("Cancel"), [data-action="cancel"]', close: '[aria-label="Close"], #gridpilot-close-btn', }; selector = mockMap[action] || `[data-action="${action}"], button:has-text("${action}")`; } else { selector = this.getActionSelector(action); } await page.waitForSelector(selector, { state: 'attached', timeout }); await this.safeClickService.safeClick(selector, { timeout }); return { success: true, target: selector }; } async clickNextButton(nextStepName: string): Promise { 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; } const timeout = IRACING_TIMEOUTS.elementWait; const nextButtonSelector = IRACING_SELECTORS.wizard.nextButton; const fallbackSelector = `.wizard-footer a.btn:has-text("${nextStepName}")`; try { this.log('debug', 'Attempting next button (primary) with forced click', { selector: nextButtonSelector }); try { await this.safeClickService.safeClick(nextButtonSelector, { timeout, force: true }); this.log('info', `Clicked next button to ${nextStepName} (primary forced)`); return; } catch (e) { this.log('debug', 'Primary forced click failed, falling back', { error: String(e) }); } this.log('debug', 'Trying fallback next button (forced)', { selector: fallbackSelector }); try { await this.safeClickService.safeClick(fallbackSelector, { timeout, force: true }); this.log('info', `Clicked next button (fallback) to ${nextStepName}`); return; } catch (e) { this.log('debug', 'Fallback forced click failed, trying last resort', { error: String(e) }); } const lastResort = '.wizard-footer a.btn:not(.disabled):last-child'; await this.safeClickService.safeClick(lastResort, { timeout, force: true }); this.log('info', `Clicked next button (last resort) to ${nextStepName}`); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', `Failed to click next button to ${nextStepName}`, { error: message }); throw new Error(`Failed to navigate to ${nextStepName}: ${message}`); } } async selectDropdown(name: string, value: string): Promise { const page = this.getPage(); const selector = this.getDropdownSelector(name); const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; try { await page.waitForSelector(selector, { state: 'attached', timeout }); await page.selectOption(selector, value); return; } catch { // fallthrough } const heuristics = [ `select[id*="${name}"]`, `select[name*="${name}"]`, `select[data-dropdown*="${name}"]`, `select`, `[data-dropdown="${name}"]`, `[data-dropdown*="${name}"]`, `[role="listbox"] select`, `[role="listbox"]`, ]; for (const h of heuristics) { try { const count = await page.locator(h).first().count().catch(() => 0); if (count > 0) { 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); return; } catch { await page.evaluate( ({ sel, val }) => { const els = Array.from(document.querySelectorAll(sel)) as HTMLSelectElement[]; for (const el of els) { try { el.value = String(val); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } catch { // ignore } } }, { sel: h, val: value }, ); return; } } else { await page.evaluate( ({ sel, val }) => { try { const container = document.querySelector(sel) as HTMLElement | null; if (!container) return; const byText = Array.from(container.querySelectorAll('button, a, li')).find((el) => { try { return (el.textContent || '').trim().toLowerCase() === String(val).trim().toLowerCase(); } catch { return false; } }); if (byText) { (byText as HTMLElement).click(); return; } const selInside = container.querySelector('select') as HTMLSelectElement | null; if (selInside) { selInside.value = String(val); selInside.dispatchEvent(new Event('input', { bubbles: true })); selInside.dispatchEvent(new Event('change', { bubbles: true })); return; } } catch { // ignore } }, { sel: h, val: value }, ); return; } } } catch { // ignore } } await page.evaluate( ({ n, v }) => { try { const selectors = [ `select[id*="${n}"]`, `select[name*="${n}"]`, `input[id*="${n}"]`, `input[name*="${n}"]`, `[data-dropdown*="${n}"]`, '[role="listbox"] select', ]; for (const s of selectors) { const els = Array.from(document.querySelectorAll(s)); if (els.length === 0) continue; for (const el of els) { try { if (el instanceof HTMLSelectElement) { el.value = String(v); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } else if (el instanceof HTMLInputElement) { el.value = String(v); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } } catch { // ignore } } if (els.length > 0) break; } } catch { // ignore } }, { n: name, v: value }, ); } async setToggle(name: string, checked: boolean): Promise { const page = this.getPage(); const primarySelector = this.getToggleSelector(name); const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; const candidates = [ primarySelector, IRACING_SELECTORS.fields.toggle, IRACING_SELECTORS.fields.checkbox, 'input[type="checkbox"]', '.switch-checkbox', '.toggle-switch input', ].filter(Boolean); const combined = candidates.join(', '); await page.waitForSelector(combined, { state: 'attached', timeout }).catch(() => {}); if (!this.isRealMode()) { try { await page.evaluate(({ cands, should }) => { document .querySelectorAll('.wizard-step.hidden, .modal.hidden, .wizard-step[hidden]') .forEach((el) => { el.classList.remove('hidden'); el.removeAttribute('hidden'); }); for (const sel of cands) { try { const els = Array.from(document.querySelectorAll(sel)) as HTMLInputElement[]; if (els.length === 0) continue; for (const el of els) { try { if ('checked' in el) { (el as HTMLInputElement).checked = Boolean(should); (el as any).dispatchEvent(new Event('change', { bubbles: true })); } else { (el as HTMLElement).setAttribute('aria-checked', String(Boolean(should))); (el as any).dispatchEvent(new Event('change', { bubbles: true })); try { (el as HTMLElement).click(); } catch { // ignore } } } catch { // ignore } } if (els.length > 0) break; } catch { // ignore } } }, { cands: candidates, should: checked }); return; } catch { // ignore } } for (const cand of candidates) { try { const locator = page.locator(cand).first(); const count = await locator.count().catch(() => 0); if (count === 0) continue; 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')) { const isChecked = await locator.isChecked().catch(() => false); if (isChecked !== checked) { await this.safeClickService.safeClick(cand, { timeout }); } return; } const ariaChecked = await locator.getAttribute('aria-checked').catch(() => ''); if (ariaChecked !== '') { const desired = String(Boolean(checked)); if (ariaChecked !== desired) { await this.safeClickService.safeClick(cand, { timeout }); } return; } await this.safeClickService.safeClick(cand, { timeout }); return; } catch { // try next } } this.log('warn', `Could not locate toggle for "${name}" to set to ${checked}`, { candidates }); } async setSlider(name: string, value: number): Promise { const page = this.getPage(); const selector = this.getSliderSelector(name); const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; const candidates = [ selector, IRACING_SELECTORS.fields.slider, 'input[id*="slider"]', 'input[id*="track-state"]', 'input[type="range"]', 'input[type="text"]', '[data-slider]', 'input[data-value]', ].filter(Boolean); if (!this.isRealMode()) { try { await page.evaluate(() => { document .querySelectorAll('.wizard-step.hidden, .modal.hidden, .wizard-step[hidden]') .forEach((el) => { el.classList.remove('hidden'); el.removeAttribute('hidden'); }); }); } catch { // ignore } for (const cand of candidates) { try { const applied = await page.evaluate( ({ sel, val }) => { try { const els = Array.from(document.querySelectorAll(sel)) as HTMLInputElement[]; if (els.length === 0) return false; for (const el of els) { try { el.value = String(val); el.setAttribute('data-value', String(val)); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } catch { // ignore } } return true; } catch { return false; } }, { sel: cand, val: value }, ); if (applied) return; } catch { // continue } } } const combined = candidates.join(', '); try { await page.waitForSelector(combined, { state: 'attached', timeout }); } catch { await page.evaluate((val) => { const heuristics = [ 'input[id*="slider"]', 'input[id*="track-state"]', '[data-slider]', 'input[data-value]', 'input[type="range"]', 'input[type="text"]', ]; for (const sel of heuristics) { try { const els = Array.from(document.querySelectorAll(sel)) as HTMLInputElement[]; if (els.length === 0) continue; for (const el of els) { try { el.value = String(val); el.setAttribute('data-value', String(val)); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } catch { // ignore } } if (els.length > 0) break; } catch { // ignore } } }, value); return; } for (const cand of candidates) { try { const locator = page.locator(cand).first(); const count = await locator.count().catch(() => 0); if (count === 0) continue; 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') { try { await locator.fill(String(value)); return; } catch { await locator.evaluate((el, val) => { try { (el as HTMLInputElement).value = String(val); el.setAttribute('data-value', String(val)); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } catch { // ignore } }, value); return; } } } try { await locator.fill(String(value)); return; } catch { await locator.evaluate((el, val) => { try { (el as HTMLInputElement).value = String(val); el.setAttribute('data-value', String(val)); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } catch { // ignore } }, value); return; } } catch { // try next } } } async selectListItem(itemId: string): Promise { const page = this.getPage(); const selector = `[data-item="${itemId}"], button:has-text("${itemId}")`; const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; if (!this.isRealMode()) { try { await page.evaluate(() => { document .querySelectorAll('.wizard-step.hidden, .modal.hidden, .wizard-step[hidden]') .forEach((el) => { el.classList.remove('hidden'); el.removeAttribute('hidden'); }); }); } catch { // ignore } } await page.waitForSelector(selector, { state: 'attached', timeout }); await this.safeClickService.safeClick(selector, { timeout }); } async openModalTrigger(type: string): Promise { const page = this.getPage(); const escaped = type.replace(/"/g, '\\"'); const selector = `button:has-text("${escaped}"), a:has-text("${escaped}"), [aria-label*="${escaped}" i], [data-action="${escaped}"], [data-modal-trigger="${escaped}"]`; const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout; if (!this.isRealMode()) { try { await page.evaluate(() => { document .querySelectorAll('.wizard-step.hidden, .modal.hidden, .wizard-step[hidden]') .forEach((el) => { el.classList.remove('hidden'); el.removeAttribute('hidden'); }); }); } catch { // ignore } } await page.waitForSelector(selector, { state: 'attached', timeout }); await this.safeClickService.safeClick(selector, { timeout }); } async clickAddCarButton(): Promise { const page = this.getPage(); const addCarButtonSelector = this.isRealMode() ? IRACING_SELECTORS.steps.addCarButton : `${IRACING_SELECTORS.steps.addCarButton}, [data-action="add-car"]`; try { this.log('info', 'Clicking Add Car button to open modal'); await page.waitForSelector(addCarButtonSelector, { state: 'attached', timeout: this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout, }); await this.safeClickService.safeClick(addCarButtonSelector); this.log('info', 'Clicked Add Car button'); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', 'Could not click Add Car button', { error: message }); throw new Error(`Failed to click Add Car button: ${message}`); } } async waitForAddCarModal(): Promise { const page = this.getPage(); try { this.log('debug', 'Waiting for Add Car modal to appear (primary selector)'); const modalSelector = IRACING_SELECTORS.steps.addCarModal; await page.waitForSelector(modalSelector, { state: 'attached', timeout: this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout, }); await page.waitForTimeout(150); this.log('info', 'Add Car modal is visible', { selector: modalSelector }); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('warn', 'Add Car modal not found with primary selector, dumping #create-race-wizard innerHTML and retrying', { error: message, }); const html = await page.innerHTML('#create-race-wizard').catch(() => ''); this.log('debug', 'create-race-wizard innerHTML (truncated)', { html: html ? html.slice(0, 2000) : '' }); this.log('info', 'Retrying wait for Add Car modal with extended timeout'); try { const modalSelectorRetry = IRACING_SELECTORS.steps.addCarModal; await page.waitForSelector(modalSelectorRetry, { state: 'attached', timeout: 10000, }); await page.waitForTimeout(150); this.log('info', 'Add Car modal found after retry', { selector: modalSelectorRetry }); } catch { this.log('warn', 'Add Car modal still not found after retry'); } } } async clickAddTrackButton(): Promise { const page = this.getPage(); const addTrackButtonSelector = IRACING_SELECTORS.steps.addTrackButton; try { this.log('info', 'Clicking Add Track button to open modal'); await page.waitForSelector(addTrackButtonSelector, { state: 'attached', timeout: this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout, }); await this.safeClickService.safeClick(addTrackButtonSelector); this.log('info', 'Clicked Add Track button'); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', 'Could not click Add Track button', { error: message }); throw new Error(`Failed to click Add Track button: ${message}`); } } async waitForAddTrackModal(): Promise { const page = this.getPage(); try { this.log('debug', 'Waiting for Add Track modal to appear'); const modalSelector = IRACING_SELECTORS.steps.addTrackModal; await page.waitForSelector(modalSelector, { state: 'attached', timeout: this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout, }); await page.waitForTimeout(150); this.log('info', 'Add Track modal is visible'); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('warn', 'Add Track modal did not appear', { error: message }); } } async selectFirstSearchResult(): Promise { const page = this.getPage(); const directSelectors = [IRACING_SELECTORS.steps.trackSelectButton, IRACING_SELECTORS.steps.carSelectButton]; for (const selector of directSelectors) { const button = page.locator(selector).first(); if ((await button.count()) > 0 && (await button.isVisible())) { await this.safeClickService.safeClick(selector, { timeout: IRACING_TIMEOUTS.elementWait }); this.log('info', 'Clicked direct Select button for first search result', { selector }); return; } } const dropdownSelector = IRACING_SELECTORS.steps.trackSelectDropdown; const dropdownButton = page.locator(dropdownSelector).first(); if ((await dropdownButton.count()) > 0 && (await dropdownButton.isVisible())) { await this.safeClickService.safeClick(dropdownSelector, { timeout: IRACING_TIMEOUTS.elementWait }); this.log('debug', 'Clicked dropdown toggle, waiting for menu', { selector: dropdownSelector }); await page.waitForSelector('.dropdown-menu.show', { timeout: 3000 }).catch(() => {}); const itemSelector = IRACING_SELECTORS.steps.trackSelectDropdownItem; await page.waitForTimeout(200); await this.safeClickService.safeClick(itemSelector, { timeout: IRACING_TIMEOUTS.elementWait }); this.log('info', 'Clicked first dropdown item to select track config', { selector: itemSelector }); return; } const carRowSelector = '.car-row, .car-item, [data-testid*="car"], [id*="favorite_cars"], [id*="select-car"]'; const carRow = page.locator(carRowSelector).first(); if ((await carRow.count()) > 0) { this.log('info', 'Fallback: clicking car row/item to select', { selector: carRowSelector }); try { await this.safeClickService.safeClick(carRowSelector, { timeout: IRACING_TIMEOUTS.elementWait }); this.log('info', 'Clicked car row fallback selector'); return; } catch (e) { this.log('debug', 'Car row fallback click failed, attempting to click first link inside row', { error: String(e), }); const linkInside = page.locator(`${carRowSelector} a, ${carRowSelector} button`).first(); if ((await linkInside.count()) > 0 && (await linkInside.isVisible())) { await this.safeClickService.safeClick( `${carRowSelector} a, ${carRowSelector} button`, { timeout: IRACING_TIMEOUTS.elementWait }, ); this.log('info', 'Clicked link/button inside car row fallback'); return; } } } throw new Error('No Select button found in modal table and no fallback car row found'); } async clickAdminModalConfirm(): Promise { const page = this.getPage(); const adminConfirmSelector = '#set-admins .modal .btn-primary, #set-admins .modal button:has-text("Add"), #set-admins .modal button:has-text("Select")'; try { await page.waitForSelector(adminConfirmSelector, { state: 'attached', timeout: IRACING_TIMEOUTS.elementWait, }); await this.safeClickService.safeClick(adminConfirmSelector, { timeout: IRACING_TIMEOUTS.elementWait }); this.log('info', 'Clicked admin modal confirm button'); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('warn', 'Could not click admin modal confirm button', { error: message }); throw new Error(`Failed to confirm admin selection: ${message}`); } } async clickNewRaceInModal(): Promise { const page = this.getPage(); try { this.log('info', 'Waiting for Create Race modal to appear'); const modalSelector = IRACING_SELECTORS.hostedRacing.createRaceModal; await page.waitForSelector(modalSelector, { state: 'attached', timeout: IRACING_TIMEOUTS.elementWait, }); this.log('info', 'Create Race modal attached, clicking New Race button'); const newRaceSelector = IRACING_SELECTORS.hostedRacing.newRaceButton; await page.waitForSelector(newRaceSelector, { state: 'attached', timeout: IRACING_TIMEOUTS.elementWait, }); await this.safeClickService.safeClick(newRaceSelector, { timeout: IRACING_TIMEOUTS.elementWait }); this.log('info', 'Clicked New Race button, waiting for form to load'); await page.waitForTimeout(500); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('error', 'Failed to click New Race in modal', { error: message }); throw new Error(`Failed to click New Race button: ${message}`); } } async selectWeatherType(weatherType: string): Promise { const page = this.browserSession.getPage(); if (!page) { throw new Error('Browser not connected'); } try { this.log('info', 'Selecting weather type via radio button', { weatherType }); const weatherTypeLower = weatherType.toLowerCase(); let labelSelector: string; if (weatherTypeLower.includes('static') || weatherType === '2') { labelSelector = 'label.chakra-radio:has-text("Static Weather")'; } else if (weatherTypeLower.includes('forecast') || weatherType === '1') { labelSelector = 'label.chakra-radio:has-text("Forecasted weather")'; } else if (weatherTypeLower.includes('timeline') || weatherTypeLower.includes('custom') || weatherType === '3') { labelSelector = 'label.chakra-radio:has-text("Timeline editor")'; } else { labelSelector = 'label.chakra-radio:has-text("Static Weather")'; this.log('warn', `Unknown weather type "${weatherType}", defaulting to Static Weather`); } const radioGroup = page.locator('[role="radiogroup"]').first(); const exists = (await radioGroup.count()) > 0; if (!exists) { this.log('debug', 'Weather radio group not found, step may be optional'); return; } const radioLabel = page.locator(labelSelector).first(); const isVisible = await radioLabel.isVisible().catch(() => false); if (isVisible) { await radioLabel.click({ timeout: IRACING_TIMEOUTS.elementWait }); this.log('info', 'Selected weather type', { weatherType, selector: labelSelector }); } else { this.log('debug', 'Weather type radio not visible, may already be selected or step is different'); } } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log('warn', 'Could not select weather type (non-critical)', { error: message, weatherType }); } } // ===== Private helper mappings ===== private getFieldSelector(fieldName: string): string { const fieldMap: Record = { sessionName: `${IRACING_SELECTORS.steps.sessionName}, ${IRACING_SELECTORS.steps.sessionNameAlt}`, password: `${IRACING_SELECTORS.steps.password}, ${IRACING_SELECTORS.steps.passwordAlt}`, description: `${IRACING_SELECTORS.steps.description}, ${IRACING_SELECTORS.steps.descriptionAlt}`, adminSearch: IRACING_SELECTORS.steps.adminSearch, carSearch: IRACING_SELECTORS.steps.carSearch, trackSearch: IRACING_SELECTORS.steps.trackSearch, maxDrivers: IRACING_SELECTORS.steps.maxDrivers, }; return fieldMap[fieldName] || IRACING_SELECTORS.fields.textInput; } private getActionSelector(action: string): string { if (action.startsWith('[') || action.startsWith('button') || action.startsWith('#')) { return action; } const actionMap: Record = { next: IRACING_SELECTORS.wizard.nextButton, back: IRACING_SELECTORS.wizard.backButton, confirm: IRACING_SELECTORS.wizard.confirmButton, cancel: IRACING_SELECTORS.wizard.cancelButton, create: IRACING_SELECTORS.hostedRacing.createRaceButton, close: IRACING_SELECTORS.wizard.closeButton, }; return actionMap[action] || `button:has-text("${action}")`; } private getDropdownSelector(name: string): string { const dropdownMap: Record = { region: IRACING_SELECTORS.steps.region, trackConfig: IRACING_SELECTORS.steps.trackConfig, weatherType: IRACING_SELECTORS.steps.weatherType, trackState: IRACING_SELECTORS.steps.trackState, }; return dropdownMap[name] || IRACING_SELECTORS.fields.select; } private getToggleSelector(name: string): string { const toggleMap: Record = { startNow: IRACING_SELECTORS.steps.startNow, rollingStart: IRACING_SELECTORS.steps.rollingStart, }; return toggleMap[name] || IRACING_SELECTORS.fields.checkbox; } private getSliderSelector(name: string): string { const sliderMap: Record = { practice: IRACING_SELECTORS.steps.practice, qualify: IRACING_SELECTORS.steps.qualify, race: IRACING_SELECTORS.steps.race, timeOfDay: IRACING_SELECTORS.steps.timeOfDay, temperature: IRACING_SELECTORS.steps.temperature, }; return sliderMap[name] || IRACING_SELECTORS.fields.slider; } }