import type { Page } from 'playwright'; import type { ILogger } from '../../../../application/ports/ILogger'; import { IRACING_SELECTORS, BLOCKED_KEYWORDS } from './IRacingSelectors'; import type { PlaywrightConfig } from '../core/PlaywrightAutomationAdapter'; import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession'; export class SafeClickService { constructor( private readonly config: Required, private readonly browserSession: PlaywrightBrowserSession, 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; } /** * Check if a selector or element text matches blocked patterns (checkout/payment buttons). * SAFETY CRITICAL: This prevents accidental purchases during automation. * * @param selector The CSS selector being clicked * @param elementText Optional text content of the element (should be direct text only) * @returns true if the selector/text matches a blocked pattern */ private isBlockedSelector(selector: string, elementText?: string): boolean { const selectorLower = selector.toLowerCase(); const textLower = elementText?.toLowerCase().trim() ?? ''; // Check if selector contains any blocked keywords for (const keyword of BLOCKED_KEYWORDS) { if (selectorLower.includes(keyword) || textLower.includes(keyword)) { return true; } } // Check for price indicators (e.g., "$0.50", "$19.99") // IMPORTANT: Only block if the price is combined with a checkout-related action word // This prevents false positives when price is merely displayed on the page const pricePattern = /\$\d+\.\d{2}/; const hasPrice = pricePattern.test(textLower) || pricePattern.test(selector); if (hasPrice) { // Only block if text also contains checkout-related words const checkoutActionWords = ['check', 'out', 'buy', 'purchase', 'pay', 'cart']; const hasCheckoutWord = checkoutActionWords.some(word => textLower.includes(word)); if (hasCheckoutWord) { return true; } } // Check for cart icon class if (selectorLower.includes('icon-cart') || selectorLower.includes('cart-icon')) { return true; } return false; } /** * Verify an element is not a blocked checkout/payment button before clicking. * SAFETY CRITICAL: Throws error if element matches blocked patterns. * * This method checks: * 1. The selector string itself for blocked patterns * 2. The element's DIRECT text content (not children/siblings) * 3. The element's class, id, and href attributes for checkout indicators * 4. Whether the element matches any blocked CSS selectors * * @param selector The CSS selector of the element to verify * @throws Error if element is a blocked checkout/payment button */ async verifyNotBlockedElement(selector: string): Promise { const page = this.browserSession.getPage(); if (!page) return; // In mock mode we bypass safety blocking to allow tests to exercise checkout flows // without risking real-world purchases. Safety checks remain active in 'real' mode. if (!this.isRealMode()) { this.log('debug', 'Mock mode detected - skipping checkout blocking checks', { selector }); return; } // First check the selector itself if (this.isBlockedSelector(selector)) { const errorMsg = `🚫 BLOCKED: Selector "${selector}" matches checkout/payment pattern. Automation stopped for safety.`; this.log('error', errorMsg); throw new Error(errorMsg); } // Try to get the element's attributes and direct text for verification try { const element = page.locator(selector).first(); const isVisible = await element.isVisible().catch(() => false); if (isVisible) { // Get element attributes for checking const elementClass = (await element.getAttribute('class').catch(() => '')) ?? ''; const elementId = (await element.getAttribute('id').catch(() => '')) ?? ''; const elementHref = (await element.getAttribute('href').catch(() => '')) ?? ''; // Check class/id/href for checkout indicators const attributeText = `${elementClass} ${elementId} ${elementHref}`.toLowerCase(); if ( attributeText.includes('checkout') || attributeText.includes('cart') || attributeText.includes('purchase') || attributeText.includes('payment') ) { const errorMsg = `🚫 BLOCKED: Element attributes contain checkout pattern. Class="${elementClass}", ID="${elementId}", Href="${elementHref}". Automation stopped for safety.`; this.log('error', errorMsg); throw new Error(errorMsg); } // Get ONLY the direct text of this element, excluding child element text // This prevents false positives when a checkout button exists elsewhere on the page const directText = await element .evaluate((el) => { let text = ''; const childNodes = Array.from(el.childNodes); for (let i = 0; i < childNodes.length; i++) { const node = childNodes[i]; if (node.nodeType === Node.TEXT_NODE) { text += node.textContent || ''; } } return text.trim(); }) .catch(() => ''); // Also get innerText as fallback (for buttons with icon + text structure) // But only check if directText is empty or very short let textToCheck = directText; if (directText.length < 3) { const innerText = await element.innerText().catch(() => ''); if (innerText.length < 100) { textToCheck = innerText.trim(); } } this.log('debug', 'Checking element text for blocked patterns', { selector, directText, textToCheck, elementClass, }); if (textToCheck && this.isBlockedSelector('', textToCheck)) { const errorMsg = `🚫 BLOCKED: Element text "${textToCheck}" matches checkout/payment pattern. Automation stopped for safety.`; this.log('error', errorMsg); throw new Error(errorMsg); } // Check if element matches any of the blocked selectors directly for (const blockedSelector of Object.values(IRACING_SELECTORS.BLOCKED_SELECTORS)) { const matchesBlocked = await element .evaluate((el, sel) => { try { return el.matches(sel) || el.closest(sel) !== null; } catch { return false; } }, blockedSelector) .catch(() => false); if (matchesBlocked) { const errorMsg = `🚫 BLOCKED: Element matches blocked selector "${blockedSelector}". Automation stopped for safety.`; this.log('error', errorMsg); throw new Error(errorMsg); } } } } catch (error) { if (error instanceof Error && error.message.includes('BLOCKED')) { throw error; } this.log('debug', 'Could not verify element (may not exist yet)', { selector, error: String(error) }); } } /** * Dismiss any visible Chakra UI modal popups that might block interactions. * This handles various modal dismiss patterns including close buttons and overlay clicks. * Optimized for speed - uses instant visibility checks and minimal waits. */ async dismissModals(): Promise { const page = this.browserSession.getPage(); if (!page) return; try { const modalContainer = page.locator('.chakra-modal__content-container, .modal-content'); const isModalVisible = await modalContainer.isVisible().catch(() => false); if (!isModalVisible) { this.log('debug', 'No modal visible, continuing'); return; } this.log('info', 'Modal detected, dismissing immediately'); const dismissButton = page .locator( '.chakra-modal__content-container button[aria-label="Continue"], ' + '.chakra-modal__content-container button:has-text("Continue"), ' + '.chakra-modal__content-container button:has-text("Close"), ' + '.chakra-modal__content-container button:has-text("OK"), ' + '.chakra-modal__close-btn, ' + '[aria-label="Close"]', ) .first(); if (await dismissButton.isVisible().catch(() => false)) { this.log('info', 'Clicking modal dismiss button'); await dismissButton.click({ force: true, timeout: 1000 }); await page.waitForTimeout(100); return; } this.log('debug', 'No dismiss button found, skipping Escape to avoid closing wizard'); await page.waitForTimeout(100); } catch (error) { this.log('debug', 'Modal dismiss error (non-critical)', { error: String(error) }); } } /** * Dismiss any open React DateTime pickers (rdt component). * These pickers can intercept pointer events and block clicks on other elements. * Used specifically before navigating away from steps that have datetime pickers. * * IMPORTANT: Do NOT use Escape key as it closes the entire wizard modal in iRacing. */ async dismissDatetimePickers(): Promise { const page = this.browserSession.getPage(); if (!page) return; try { const initialCount = await page.locator('.rdt.rdtOpen').count(); if (initialCount === 0) { this.log('debug', 'No datetime picker open'); return; } this.log('info', `Closing ${initialCount} open datetime picker(s)`); // Strategy 1: remove rdtOpen class via JS await page.evaluate(() => { const openPickers = document.querySelectorAll('.rdt.rdtOpen'); openPickers.forEach((picker) => { picker.classList.remove('rdtOpen'); }); const activeEl = document.activeElement as HTMLElement; if (activeEl && activeEl.blur && activeEl.closest('.rdt')) { activeEl.blur(); } }); await page.waitForTimeout(50); let stillOpenCount = await page.locator('.rdt.rdtOpen').count(); if (stillOpenCount === 0) { this.log('debug', 'Datetime pickers closed via JavaScript'); return; } // Strategy 2: click outside this.log('debug', `${stillOpenCount} picker(s) still open, clicking outside`); const modalBody = page.locator(IRACING_SELECTORS.wizard.modalContent).first(); if (await modalBody.isVisible().catch(() => false)) { const cardHeader = page.locator(`${IRACING_SELECTORS.wizard.stepContainers.timeOfDay} .card-header`).first(); if (await cardHeader.isVisible().catch(() => false)) { await cardHeader.click({ force: true, timeout: 1000 }).catch(() => {}); await page.waitForTimeout(100); } } stillOpenCount = await page.locator('.rdt.rdtOpen').count(); if (stillOpenCount === 0) { this.log('debug', 'Datetime pickers closed via click outside'); return; } // Strategy 3: blur inputs and force-remove rdtOpen this.log('debug', `${stillOpenCount} picker(s) still open, force blur`); await page.evaluate(() => { const rdtInputs = document.querySelectorAll('.rdt input'); rdtInputs.forEach((input) => { (input as HTMLElement).blur(); }); const openPickers = document.querySelectorAll('.rdt.rdtOpen'); openPickers.forEach((picker) => { picker.classList.remove('rdtOpen'); const pickerDropdown = picker.querySelector('.rdtPicker') as HTMLElement; if (pickerDropdown) { pickerDropdown.style.display = 'none'; } }); }); await page.waitForTimeout(50); const finalCount = await page.locator('.rdt.rdtOpen').count(); if (finalCount > 0) { this.log('warn', `Could not close ${finalCount} datetime picker(s), will attempt click with force`); } else { this.log('debug', 'Datetime picker dismiss complete'); } } catch (error) { this.log('debug', 'Datetime picker dismiss error (non-critical)', { error: String(error) }); } } /** * Safe click wrapper that handles modal interception errors with auto-retry. * If a click fails because a modal is intercepting pointer events, this method * will dismiss the modal and retry the click operation. * * SAFETY: Before any click, verifies the target is not a checkout/payment button. * * @param selector The CSS selector of the element to click * @param options Click options including timeout and force * @returns Promise that resolves when click succeeds or throws after max retries */ async safeClick( selector: string, options?: { timeout?: number; force?: boolean }, ): Promise { const page = this.getPage(); // In mock mode, ensure mock fixtures are visible (remove 'hidden' flags) 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 any evaluation errors in test environments } } // SAFETY CHECK: Verify this is not a checkout/payment button await this.verifyNotBlockedElement(selector); const maxRetries = 3; const timeout = options?.timeout ?? this.config.timeout; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const useForce = options?.force || attempt === maxRetries; await page.click(selector, { timeout, force: useForce }); return; } catch (error) { if (error instanceof Error && error.message.includes('BLOCKED')) { throw error; } const errorMessage = String(error); if ( errorMessage.includes('intercepts pointer events') || errorMessage.includes('chakra-modal') || errorMessage.includes('chakra-portal') || errorMessage.includes('rdtDay') || errorMessage.includes('rdtPicker') || errorMessage.includes('rdt') ) { this.log('info', `Element intercepting click (attempt ${attempt}/${maxRetries}), dismissing...`, { selector, attempt, maxRetries, }); await this.dismissDatetimePickers(); await this.dismissModals(); await page.waitForTimeout(200); if (attempt === maxRetries) { this.log('warn', 'Max retries reached, attempting JS click fallback', { selector }); try { const clicked = await page.evaluate((sel) => { try { const el = document.querySelector(sel) as HTMLElement | null; if (!el) return false; el.scrollIntoView({ block: 'center', inline: 'center' }); el.click(); return true; } catch { return false; } }, selector); if (clicked) { this.log('info', 'JS fallback click succeeded', { selector }); return; } else { this.log('debug', 'JS fallback click did not find element or failed', { selector }); } } catch (e) { this.log('debug', 'JS fallback click error', { selector, error: String(e) }); } this.log('error', 'Max retries reached, click still blocked', { selector }); throw error; } } else { throw error; } } } } }