1094 lines
40 KiB
TypeScript
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;
|
|
}
|
|
} |