431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
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<PlaywrightConfig>,
|
|
private readonly browserSession: PlaywrightBrowserSession,
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
const page = this.getPage();
|
|
|
|
// In mock mode, ensure mock fixtures are visible (remove 'hidden' flags)
|
|
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 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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |