/** * Playwright-style actionability checks for the humanize layer. * * Checks: attached, visible, stable, enabled, editable, receives pointer events. * Retry loop with backoff matching Playwright internals: [100, 250, 500, 1000]ms. */ import type { Page, Frame, ElementHandle } from 'playwright-core'; // --------------------------------------------------------------------------- // Error hierarchy // --------------------------------------------------------------------------- export class ActionabilityError extends Error { selector: string; check: string; constructor(selector: string, check: string, message: string) { super(`Element ${JSON.stringify(selector)} failed ${check} check: ${message}`); this.name = 'ActionabilityError'; this.selector = selector; this.check = check; } } export class ElementNotAttachedError extends ActionabilityError { constructor(selector: string) { super(selector, 'attached', 'element not found in DOM'); this.name = 'ElementNotAttachedError'; } } export class ElementNotVisibleError extends ActionabilityError { constructor(selector: string) { super(selector, 'visible', 'element is not visible'); this.name = 'ElementNotVisibleError'; } } export class ElementNotStableError extends ActionabilityError { constructor(selector: string) { super(selector, 'stable', 'element position is still changing'); this.name = 'ElementNotStableError'; } } export class ElementNotEnabledError extends ActionabilityError { constructor(selector: string) { super(selector, 'enabled', 'element is disabled'); this.name = 'ElementNotEnabledError'; } } export class ElementNotEditableError extends ActionabilityError { constructor(selector: string) { super(selector, 'editable', 'element is not editable'); this.name = 'ElementNotEditableError'; } } export class ElementNotReceivingEventsError extends ActionabilityError { coveringTag: string; constructor(selector: string, coveringTag: string = 'unknown') { super(selector, 'pointer_events', `element is covered by <${coveringTag}>`); this.name = 'ElementNotReceivingEventsError'; this.coveringTag = coveringTag; } } // --------------------------------------------------------------------------- // Check-set constants // --------------------------------------------------------------------------- export type CheckName = 'attached' | 'visible' | 'enabled' | 'editable' | 'pointer_events'; export const CHECKS_CLICK: ReadonlySet = new Set(['attached', 'visible', 'enabled', 'pointer_events']); export const CHECKS_HOVER: ReadonlySet = new Set(['attached', 'visible', 'pointer_events']); export const CHECKS_INPUT: ReadonlySet = new Set(['attached', 'visible', 'enabled', 'editable', 'pointer_events']); export const CHECKS_FOCUS: ReadonlySet = new Set(['attached', 'visible', 'enabled']); export const CHECKS_CHECK: ReadonlySet = new Set(['attached', 'visible', 'enabled', 'pointer_events']); const BACKOFF_MS = [100, 250, 500, 1000]; function backoffSleep(attempt: number): Promise { const idx = Math.min(attempt, BACKOFF_MS.length - 1); return new Promise(resolve => setTimeout(resolve, BACKOFF_MS[idx])); } // --------------------------------------------------------------------------- // Pre-scroll actionability // --------------------------------------------------------------------------- export async function ensureActionable( pageOrFrame: Page | Frame, selector: string, checks: ReadonlySet, timeout: number = 30000, force: boolean = false, ): Promise { if (force) return; const deadline = Date.now() + timeout; let attempt = 0; let lastError: ActionabilityError | null = null; while (true) { const remainingMs = Math.max(0, deadline - Date.now()); if (remainingMs <= 0) { if (lastError) throw lastError; throw new ActionabilityError(selector, 'timeout', 'timeout expired before first check'); } try { const loc = pageOrFrame.locator(selector).first(); if (checks.has('attached')) { try { await loc.waitFor({ state: 'attached', timeout: Math.max(1, Math.min(remainingMs, 2000)) }); } catch { throw new ElementNotAttachedError(selector); } } if (checks.has('visible')) { if (!await loc.isVisible()) throw new ElementNotVisibleError(selector); } if (checks.has('enabled')) { if (!await loc.isEnabled()) throw new ElementNotEnabledError(selector); } if (checks.has('editable')) { if (!await loc.isEditable()) throw new ElementNotEditableError(selector); } return; } catch (e) { if (e instanceof ActionabilityError) { lastError = e; if (Date.now() >= deadline) throw lastError; await backoffSleep(attempt); attempt++; } else { throw e; } } } } // --------------------------------------------------------------------------- // Post-scroll stability check // --------------------------------------------------------------------------- function boxesDiffer( a: { x: number; y: number; width: number; height: number }, b: { x: number; y: number; width: number; height: number }, ): boolean { return ( Math.abs(a.x - b.x) > 1 || Math.abs(a.y - b.y) > 1 || Math.abs(a.width - b.width) > 1 || Math.abs(a.height - b.height) > 1 ); } export async function ensureStable( pageOrFrame: Page | Frame, selector: string, timeout: number = 5000, ): Promise { const deadline = Date.now() + timeout; let attempt = 0; while (true) { const remainingMs = Math.max(0, deadline - Date.now()); if (remainingMs <= 0) throw new ElementNotStableError(selector); const loc = pageOrFrame.locator(selector).first(); const box1 = await loc.boundingBox({ timeout: Math.max(1, Math.min(remainingMs, 1000)) }); if (!box1) throw new ElementNotAttachedError(selector); await new Promise(r => setTimeout(r, 100)); const box2 = await loc.boundingBox({ timeout: Math.max(1, Math.min(remainingMs, 1000)) }); if (!box2) throw new ElementNotAttachedError(selector); if (!boxesDiffer(box1, box2)) return; if (Date.now() >= deadline) throw new ElementNotStableError(selector); await backoffSleep(attempt); attempt++; } } // --------------------------------------------------------------------------- // Pointer-events check (post-scroll, at actual click coordinates) // --------------------------------------------------------------------------- const POINTER_EVENTS_LOCATOR_JS = `(expected, data) => { const rect = expected.getBoundingClientRect(); const frameOffsetX = data.box ? data.box.x - rect.x : 0; const frameOffsetY = data.box ? data.box.y - rect.y : 0; const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY); if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' }; let node = target; while (node) { if (node === expected) return { hit: true }; node = node.parentNode; } if (expected.contains(target)) return { hit: true }; return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' }; }`; const POINTER_EVENTS_HANDLE_JS = `(expected, data) => { const rect = expected.getBoundingClientRect(); const frameOffsetX = data.box ? data.box.x - rect.x : 0; const frameOffsetY = data.box ? data.box.y - rect.y : 0; const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY); if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' }; let node = target; while (node) { if (node === expected) return { hit: true }; node = node.parentNode; } if (expected.contains(target)) return { hit: true }; return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' }; }`; export async function checkPointerEvents( pageOrFrame: Page | Frame, selector: string, x: number, y: number, stealth?: { evaluate(expression: string): Promise } | null, timeout: number = 5000, ): Promise { const deadline = Date.now() + timeout; let attempt = 0; while (true) { let result: any = null; try { const loc = pageOrFrame.locator(selector).first(); const box = await loc.boundingBox({ timeout: Math.max(1, Math.min(deadline - Date.now(), 1000)) }); result = await loc.evaluate(POINTER_EVENTS_LOCATOR_JS, { x, y, box }); } catch { result = null; } if (!result || result.hit) return; const covering = (result as any)?.covering ?? 'unknown'; if (Date.now() >= deadline) throw new ElementNotReceivingEventsError(selector, covering); await backoffSleep(attempt); attempt++; } } // --------------------------------------------------------------------------- // ElementHandle variant // --------------------------------------------------------------------------- export async function ensureActionableHandle( el: ElementHandle, checks: ReadonlySet, timeout: number = 30000, force: boolean = false, ): Promise { if (force) return; const deadline = Date.now() + timeout; let attempt = 0; let lastError: ActionabilityError | null = null; const label = ''; while (true) { const remainingMs = Math.max(0, deadline - Date.now()); if (remainingMs <= 0) { if (lastError) throw lastError; throw new ActionabilityError(label, 'timeout', 'timeout expired before first check'); } try { if (checks.has('visible')) { try { await el.waitForElementState('visible', { timeout: Math.max(1, Math.min(remainingMs, 2000)) }); } catch { throw new ElementNotVisibleError(label); } } if (checks.has('enabled')) { try { await el.waitForElementState('enabled', { timeout: Math.max(1, Math.min(remainingMs, 2000)) }); } catch { throw new ElementNotEnabledError(label); } } if (checks.has('editable')) { try { await el.waitForElementState('editable', { timeout: Math.max(1, Math.min(remainingMs, 2000)) }); } catch { throw new ElementNotEditableError(label); } } return; } catch (e) { if (e instanceof ActionabilityError) { lastError = e; if (Date.now() >= deadline) throw lastError; await backoffSleep(attempt); attempt++; } else { throw e; } } } } export async function checkPointerEventsHandle( el: ElementHandle, x: number, y: number, timeout: number = 5000, ): Promise { const deadline = Date.now() + timeout; let attempt = 0; while (true) { let result: any; try { const box = await el.boundingBox(); result = await el.evaluate(POINTER_EVENTS_HANDLE_JS, { x, y, box }); } catch { result = null; } if (!result || result.hit) return; const covering = (result as any)?.covering ?? 'unknown'; if (Date.now() >= deadline) throw new ElementNotReceivingEventsError('', covering); await backoffSleep(attempt); attempt++; } }