Files
gridpilot.gg/packages/infrastructure/adapters/automation/dom/IRacingDomInteractor.ts
2025-11-30 23:00:48 +01:00

1094 lines
40 KiB
TypeScript

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<PlaywrightConfig>,
private readonly browserSession: PlaywrightBrowserSession,
private readonly safeClickService: SafeClickService,
private readonly logger?: ILogger,
) {}
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): 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<FormFillResult> {
const page = this.browserSession.getPage();
if (!page) {
return { success: false, fieldName, valueSet: value, error: 'Browser not connected' };
}
const fieldMap: Record<string, string> = {
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<HTMLElement>('.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<ClickResult> {
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<ModalResult> {
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<FormFillResult> {
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<HTMLElement>('.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<FormFillResult> {
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<ClickResult> {
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<string, string> = {
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<void> {
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<void> {
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<void> {
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<HTMLElement>('.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<void> {
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<HTMLElement>('.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<void> {
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<HTMLElement>('.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<void> {
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<HTMLElement>('.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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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;
}
}