hover
Hover over a web element specified by its CSS selector to trigger hover effects and inspect behavior.
Instructions
Hover an element on the page
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| selector | Yes | CSS selector for element to hover |
Implementation Reference
- HoverTool class - the main handler for the 'hover' tool. Extends BrowserToolBase, defines the tool metadata (name: 'hover', description, input schema with required 'selector' string), and implements the execute method that uses createScopedLocator and selectPreferredLocator to find the element and call Playwright's element.hover().
import { BrowserToolBase } from '../base.js'; import { ToolContext, ToolResponse, ToolMetadata, SessionConfig, ANNOTATIONS, createSuccessResponse } from '../../common/types.js'; /** * Tool for hovering over elements */ export class HoverTool extends BrowserToolBase { static getMetadata(sessionConfig?: SessionConfig): ToolMetadata { return { name: "hover", description: "Hover an element on the page", annotations: ANNOTATIONS.interaction, inputSchema: { type: "object", properties: { selector: { type: "string", description: "CSS selector for element to hover" }, }, required: ["selector"], }, }; } async execute(args: any, context: ToolContext): Promise<ToolResponse> { this.recordInteraction(); return this.safeExecute(context, async (page) => { // Use standard element selection with error on multiple matches const locator = await this.createScopedLocator(page, args.selector); const { element } = await this.selectPreferredLocator(locator, { errorOnMultiple: true, originalSelector: args.selector, }); await element.hover(); return createSuccessResponse(`Hovered ${args.selector}`); }); } } - getMetadata() method provides the input schema for hover: an object with a required 'selector' (string) property, and uses ANNOTATIONS.interaction preset.
static getMetadata(sessionConfig?: SessionConfig): ToolMetadata { return { name: "hover", description: "Hover an element on the page", annotations: ANNOTATIONS.interaction, inputSchema: { type: "object", properties: { selector: { type: "string", description: "CSS selector for element to hover" }, }, required: ["selector"], }, }; } - src/tools/common/registry.ts:11-23 (registration)registerTool function adds tool classes to the registry. HoverTool is registered indirectly via registerTools(BROWSER_TOOL_CLASSES) on line 65.
function registerTool(toolClass: ToolClass): void { const metadata = toolClass.getMetadata(); toolClasses.set(metadata.name, toolClass); if (toolClass.prototype instanceof BrowserToolBase) { browserToolNames.add(metadata.name); } } export function registerTools(toolClassList: ToolClass[]): void { for (const toolClass of toolClassList) { registerTool(toolClass); } } - src/tools/browser/register.ts:53-68 (registration)BROWSER_TOOL_CLASSES array includes HoverTool at entry, registering hover tool into the tool registry.
export const BROWSER_TOOL_CLASSES: ToolClass[] = [ // Navigation (5) NavigateTool, GoHistoryTool, ScrollToElementTool, ScrollByTool, // Lifecycle (2) CloseTool, SetColorSchemeTool, // Interaction (7) ClickTool, FillTool, SelectTool, HoverTool, - src/tools/browser/base.ts:1-762 (helper)BrowserToolBase is the base class extended by HoverTool. It provides safeExecute (error-handled execution wrapper), createScopedLocator (dialog-aware selector scoping), selectPreferredLocator (element disambiguation), and normalizeSelector (shortcut handling) which are all used by the hover tool's execute method.
import type { Browser, Locator, Page } from 'playwright'; import { ToolHandler, ToolContext, ToolResponse, createErrorResponse } from '../common/types.js'; /** * Base class for all browser-based tools * Provides common functionality and error handling */ export abstract class BrowserToolBase implements ToolHandler { protected server: any; constructor(server: any) { this.server = server; } /** * Main execution method that all tools must implement */ abstract execute(args: any, context: ToolContext): Promise<ToolResponse>; /** * Normalize selector shortcuts and fix common escaping mistakes safely. * - "testid:foo" → "[data-testid=\"foo\"]" * - "data-test:bar" → "[data-test=\"bar\"]" * - "data-cy:baz" → "[data-cy=\"baz\"]" * - Convert simple ID-only selectors with special chars to Playwright's id engine: * "#radix-\:rc\:-content-123" → "id=radix-:rc:-content-123" * - Remove unnecessary escapes for bracket characters only (\\[ and \\]) * DO NOT unescape colons globally — colons in class/ID names must stay escaped in CSS. * * Note: the `dialog::SELECTOR` scope shortcut (e.g., `dialog::section`, * `dialog::testid:close`) is NOT handled here — it is a runtime scope * resolved by `createScopedLocator()`, not a syntactic rewrite. * * @param selector The selector string * @returns Normalized selector */ protected normalizeSelector(selector: string): string { const raw = selector.trim(); if (!raw) { return ''; } const prefixMap: Record<string, string> = { 'testid:': 'data-testid', 'data-test:': 'data-test', 'data-cy:': 'data-cy', }; // Handle testid shortcuts first for (const [prefix, attr] of Object.entries(prefixMap)) { if (raw.startsWith(prefix)) { const rest = raw.slice(prefix.length); // Allow combined selectors like: // "testid:chat-buttons button:first-child" // "testid:chat-buttons\n button:first-child" // We treat the portion immediately after the prefix up to the first // whitespace or combinator as the attribute value, and append the tail. const splitIndex = rest.search(/[\s>+~,]/); if (splitIndex === -1) { return `[${attr}="${rest}"]`; } const attrValue = rest.slice(0, splitIndex); const tail = rest.slice(splitIndex); return `[${attr}="${attrValue}"]${tail}`; } } const trimmed = raw; // Helper: unescape simple backslash-escapes used inside IDs (e.g., \:, \[, \]) const unescapeCssIdentifier = (s: string): string => { // Collapse multiple backslashes before a single char to the char itself return s .replace(/\\+:/g, ':') .replace(/\\+\[/g, '[') .replace(/\\+\]/g, ']'); }; // If this looks like a simple, standalone ID selector (no combinators or descendants), // switch to Playwright's id engine. This avoids CSS escaping pitfalls with colons. if (/^#[^\s>+~]+$/.test(trimmed)) { const idToken = trimmed.slice(1); // Only switch to id= engine if ID contains characters that commonly break CSS (#... with colons or escapes) if (idToken.includes('\\') || idToken.includes(':') || idToken.includes('[') || idToken.includes(']')) { const idValue = unescapeCssIdentifier(idToken); return `id=${idValue}`; } // Otherwise, keep simple IDs as-is return trimmed; } // For general CSS selectors, preserve required escapes for special chars. // Collapse over-escaping (e.g., \\\\[ → \\[, but keep a single backslash before [ ] :) let cleaned = trimmed; cleaned = cleaned.replace(/\\{2,}(?=\[)/g, '\\'); cleaned = cleaned.replace(/\\{2,}(?=\])/g, '\\'); cleaned = cleaned.replace(/\\{2,}(?=:)/g, '\\'); return cleaned; } /** * Build a Playwright `Locator` honoring the `dialog::SELECTOR` scope shortcut. * * - `dialog::section` → topmost open dialog/sheet, then `section` inside it * - `dialog::testid:close` → topmost open dialog, then `[data-testid="close"]` inside it * - `dialog::` → the topmost open dialog itself * - anything else → `page.locator(normalizeSelector(rawSelector))` * * "Topmost" is determined by the highest effective z-index — for each * candidate dialog, we walk up to the nearest positioned ancestor (almost * always the backdrop/glass-screen wrapper, which is what stacking actually * follows) and read its z-index. DOM order is the tiebreaker. This is more * robust than picking `.last()` because portal frameworks don't always * append in z-order, and modal stacking is driven by the backdrop's * z-index, not the dialog content's. */ protected async createScopedLocator(page: Page, rawSelector: string): Promise<Locator> { const trimmed = (rawSelector ?? '').trim(); const DIALOG_PREFIX = 'dialog::'; if (!trimmed.startsWith(DIALOG_PREFIX)) { return page.locator(this.normalizeSelector(trimmed)); } const dialogRoots = '[role="dialog"]:not([aria-hidden="true"]),' + '[role="alertdialog"]:not([aria-hidden="true"]),' + 'dialog[open]'; // Match detectActiveModal: include only user-visible candidates and // rank by effective z-index. Without the visibility filter, a hidden // dialog left in the DOM (display:none) could be picked over an // actually-open one. const result = await page.evaluate((rootsSelector: string) => { const isUserVisible = (el: Element): boolean => { const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) { return false; } const rect = (el as HTMLElement).getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }; const allCandidates = Array.from(document.querySelectorAll(rootsSelector)); const visibleIndices: number[] = []; allCandidates.forEach((el, i) => { if (isUserVisible(el)) visibleIndices.push(i); }); if (visibleIndices.length === 0) return { topIndex: -1, hasVisible: false }; if (visibleIndices.length === 1) return { topIndex: visibleIndices[0], hasVisible: true }; const effectiveZ = (start: Element): number => { let z = 0; let node: Element | null = start; while (node && node !== document.body) { const cs = getComputedStyle(node); if (cs.position !== 'static') { const parsed = parseInt(cs.zIndex, 10); if (!isNaN(parsed)) { z = Math.max(z, parsed); } } node = node.parentElement; } return z; }; let bestIdx = visibleIndices[0]; let bestScore = -Infinity; visibleIndices.forEach((i) => { // Tiebreaker: DOM order — later element is on top. const score = effectiveZ(allCandidates[i]) * 1_000_000 + i; if (score > bestScore) { bestScore = score; bestIdx = i; } }); return { topIndex: bestIdx, hasVisible: true }; }, dialogRoots).catch(() => ({ topIndex: -1, hasVisible: false })); // No visible dialog → return a never-matching locator so downstream // callers see a clean "No elements found" instead of silently scoping // to a hidden dialog left in the DOM. if (!result.hasVisible) { return page.locator('dialog-no-such-element-sentinel'); } const topmostDialog = page.locator(dialogRoots).nth(result.topIndex); const inner = trimmed.slice(DIALOG_PREFIX.length).trim(); if (!inner) { return topmostDialog; } return topmostDialog.locator(this.normalizeSelector(inner)); } /** * Detect a "user-dominating" open modal — i.e. one that a human would * visually focus on and interact with to the exclusion of the rest of the * page. Used by inspect_dom / get_text / get_html to auto-scope when no * selector is provided, so the LLM's view matches the human's view. * * Strict criterion: requires `aria-modal="true"` (or native `dialog[open]`) * because non-modal `[role="dialog"]` includes things like side panels and * tooltips that don't dominate the page. * * Returns null if no active modal is open. Otherwise returns the topmost * one, ranked by the same z-index walk used by `createScopedLocator()`. */ protected async detectActiveModal(page: Page): Promise<{ descriptor: string; suggestion: string; } | null> { const ACTIVE_MODAL_SELECTOR = '[role="dialog"][aria-modal="true"]:not([aria-hidden="true"]),' + '[role="alertdialog"][aria-modal="true"]:not([aria-hidden="true"]),' + 'dialog[open]'; return await page.evaluate((rootsSelector: string) => { const isUserVisible = (el: Element): boolean => { const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) { return false; } const rect = (el as HTMLElement).getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }; const candidates = Array.from(document.querySelectorAll(rootsSelector)).filter(isUserVisible); if (candidates.length === 0) return null; const effectiveZ = (start: Element): number => { let z = 0; let node: Element | null = start; while (node && node !== document.body) { const cs = getComputedStyle(node); if (cs.position !== 'static') { const parsed = parseInt(cs.zIndex, 10); if (!isNaN(parsed)) { z = Math.max(z, parsed); } } node = node.parentElement; } return z; }; let bestIdx = 0; let bestScore = -Infinity; candidates.forEach((el, i) => { const score = effectiveZ(el) * 1_000_000 + i; if (score > bestScore) { bestScore = score; bestIdx = i; } }); const top = candidates[bestIdx]; const tag = top.tagName.toLowerCase(); const role = top.getAttribute('role') || (tag === 'dialog' ? 'dialog' : ''); const testid = top.getAttribute('data-testid') || top.getAttribute('data-test') || top.getAttribute('data-cy'); const id = (top as HTMLElement).id || null; const ariaLabel = top.getAttribute('aria-label'); const ariaLabelledBy = top.getAttribute('aria-labelledby'); let labelText: string | null = null; if (ariaLabelledBy) { const labelEl = document.getElementById(ariaLabelledBy); labelText = labelEl?.textContent?.trim() || null; } const parts = [`<${tag}`]; if (role) parts.push(`role="${role}"`); if (testid) parts.push(`data-testid="${testid}"`); else if (id) parts.push(`id="${id}"`); if (ariaLabel) parts.push(`aria-label="${ariaLabel}"`); else if (labelText) parts.push(`labelled="${labelText.slice(0, 60)}"`); parts[parts.length - 1] += '>'; return { descriptor: parts.join(' '), suggestion: testid ? `dialog::testid:${testid}` : 'dialog::', }; }, ACTIVE_MODAL_SELECTOR).then( (result) => { // Defensive: only treat as a real modal if the result is a // properly-shaped object. Mocked test environments may return // arbitrary values from page.evaluate() that should not trigger // auto-scope. if (result && typeof result === 'object' && typeof (result as any).descriptor === 'string') { return result as { descriptor: string; suggestion: string }; } return null; }, () => null, ); } /** * Sanitize verbose Playwright selector engine messages by removing stack traces and * keeping only the essential syntax error information. */ protected sanitizeSelectorEngineMessage(msg: string): string { if (!msg) return ''; // Prefer to cut at the common phrase used by the browser const cutoffPhrases = [ "is not a valid selector.", "is not a valid selector", ]; for (const phrase of cutoffPhrases) { const idx = msg.indexOf(phrase); if (idx !== -1) { return msg.slice(0, idx + phrase.length).trim(); } } // Otherwise remove stack-like lines (e.g., " at query (…)") const lines = msg.split(/\r?\n/); const filtered = lines.filter(l => !/^\s*at\b/.test(l) && !/<anonymous>:\d+:\d+/.test(l)); return filtered.join('\n').trim(); } /** * Ensures a page is available and returns it * @param context The tool context containing browser and page * @returns The page or null if not available */ protected ensurePage(context: ToolContext): Page | null { if (!context.page) { return null; } return context.page; } /** * Validates that a page is available and returns an error response if not * @param context The tool context * @returns Either null if page is available, or an error response */ protected validatePageAvailable(context: ToolContext): ToolResponse | null { if (!this.ensurePage(context)) { return createErrorResponse("Browser page not initialized!"); } return null; } /** * Safely executes a browser operation with proper error handling * @param context The tool context * @param operation The async operation to perform * @returns The tool response */ protected async safeExecute( context: ToolContext, operation: (page: Page) => Promise<ToolResponse> ): Promise<ToolResponse> { const pageError = this.validatePageAvailable(context); if (pageError) return pageError; try { // Verify browser is connected before proceeding if (context.browser && !context.browser.isConnected()) { // If browser exists but is disconnected, reset state const { resetBrowserState } = await import('../../toolHandler.js'); resetBrowserState(); return createErrorResponse("Browser is disconnected. Please retry the operation."); } // Check if page is closed if (context.page.isClosed()) { return createErrorResponse("Page is closed. Please retry the operation."); } return await operation(context.page!); } catch (error) { const errorMessage = (error as Error).message; // Check for common browser disconnection errors if ( errorMessage.includes("Target page, context or browser has been closed") || errorMessage.includes("Target closed") || errorMessage.includes("Browser has been disconnected") || errorMessage.includes("Protocol error") || errorMessage.includes("Connection closed") ) { // Reset browser state on connection issues const { resetBrowserState } = await import('../../toolHandler.js'); resetBrowserState(); return createErrorResponse(`Browser connection error: ${errorMessage}. Connection has been reset - please retry the operation.`); } return createErrorResponse(`Operation failed: ${errorMessage}`); } } /** * Record that a user interaction occurred (for console log filtering) */ protected recordInteraction(): void { import('../../toolHandler.js').then(({ updateLastInteractionTimestamp }) => { updateLastInteractionTimestamp(); }); } /** * Record that a navigation occurred (for console log filtering) */ protected recordNavigation(): void { import('../../toolHandler.js').then(({ updateLastNavigationTimestamp, updateLastInteractionTimestamp }) => { updateLastNavigationTimestamp(); updateLastInteractionTimestamp(); }); } /** * Select preferred element from a Playwright locator (for tools using Playwright API) * Prefers first visible element, falls back to first element if none visible * * Usage for inspection tools (allow multiple, support elementIndex): * ```typescript * const locator = page.locator(selector); * const { element, elementIndex, totalCount } = await this.selectPreferredLocator(locator, { * elementIndex: args.elementIndex // optional 1-based index * }); * const warning = this.formatElementSelectionInfo(selector, elementIndex, totalCount); * ``` * * Usage for interaction tools (error on multiple): * ```typescript * const locator = page.locator(selector); * const { element } = await this.selectPreferredLocator(locator, { * errorOnMultiple: true, * originalSelector: args.selector // for error message * }); * // Will throw if multiple elements found * await element.click(); * ``` * * @param locator Playwright locator that may match multiple elements * @param options Configuration options * @param options.elementIndex Optional 1-based index to select specific element (for inspection tools) * @param options.errorOnMultiple If true, throw error when multiple elements match (for interaction tools) * @param options.originalSelector Original selector string for error messages * @returns Object with { element: Locator, elementIndex: number, totalCount: number } */ protected async selectPreferredLocator( locator: any, options?: { elementIndex?: number; errorOnMultiple?: boolean; originalSelector?: string; } ): Promise<{ element: any; elementIndex: number; totalCount: number; }> { let count: number; try { count = await locator.count(); } catch (error) { // Catch selector syntax errors and provide helpful guidance const errorMsg = (error as Error).message; const selector = options?.originalSelector || 'selector'; if (errorMsg.includes('Unexpected token') || errorMsg.includes('Invalid selector') || errorMsg.includes('SyntaxError') || errorMsg.includes('selector')) { const conciseMsg = this.sanitizeSelectorEngineMessage(errorMsg); // Helpful, accurate guidance with Tailwind-style examples const tips = [ '💡 Tips:', ' • Tailwind arbitrary values need escaping in class selectors: .min-w-\\[300px\\]', ' • Colons in class names must be escaped: .dark\\:bg-gray-700', ' • Prefer robust selectors: use testid:name or [data-testid="..."]', ' • Attribute selectors avoid escaping issues: [class*="min-w-[300px]"]', '', 'Examples:', ' ✓ .min-w-\\[300px\\] .flex-1', ' ✓ testid:submit-button', ' ✓ #login-form' ].join('\n'); throw new Error( `Invalid CSS selector: "${selector}"\n\n` + (conciseMsg ? `Selector syntax error: ${conciseMsg}\n\n` : '') + tips ); } // Re-throw other errors as-is throw error; } if (count === 0) { throw new Error('No elements found'); } // Check for multiple elements with errorOnMultiple flag if (options?.errorOnMultiple && count > 1) { const selector = options.originalSelector || 'selector'; let message = `Selector "${selector}" matched ${count} elements. Please use a more specific selector.`; // Verbose disambiguation guidance is rate-limited per session — useful // once for the agent to learn the pattern, noise on every subsequent call. // After the first emit, fall back to a one-line pointer. const { hasShownNthHint, markNthHintShown } = await import('../../toolHandler.js'); if (!hasShownNthHint()) { const guidance = [ `1) Preferred: add a unique data-testid and select it directly (e.g., testid:submit).`, `2) If you cannot change markup: append \`>> nth=<index>\` to target a specific match.`, ]; message += `\n${guidance.join('\n')}`; markNthHintShown(); } else { message += `\nUse a more specific selector (e.g. testid:..., or '>> nth=<index>').`; } // Per-call match details remain — they describe what's actually on the // page, not generic advice. const matchesDetails = await this.describeMatchedElements(locator, selector, count); message += `\n\nMatches:\n${matchesDetails}`; throw new Error(message); } // Handle explicit element index (1-based) if (options?.elementIndex !== undefined) { const idx = options.elementIndex; if (idx < 1 || idx > count) { throw new Error( `Only ${count} element(s) found, cannot select element ${idx}` ); } return { element: locator.nth(idx - 1), // Convert to 0-based elementIndex: idx - 1, totalCount: count, }; } // Single element - return it if (count === 1) { return { element: locator.first(), elementIndex: 0, totalCount: 1, }; } // Multiple elements - prefer first visible one for (let i = 0; i < count; i++) { const nth = locator.nth(i); const isVisible = await nth.isVisible(); if (isVisible) { return { element: nth, elementIndex: i, totalCount: count, }; } } // No visible elements - fall back to first return { element: locator.first(), elementIndex: 0, totalCount: count, }; } /** * Format a message indicating which element was selected when multiple match * * @param selector Original selector string * @param elementIndex 0-based index of selected element * @param totalCount Total number of matching elements * @param preferredVisible Whether visibility preference was used * @returns Formatted string or empty if only one element */ protected async formatElementSelectionInfo( selector: string, elementIndex: number, totalCount: number, preferredVisible: boolean = true ): Promise<string> { const usesNth = selector.includes('>> nth='); if (totalCount <= 1) { // Even when a single element is ultimately targeted, discourage nth usage // because it is brittle across layout/content changes. if (usesNth) { return "💡 Tip: Selector uses '>> nth='. Prefer adding a unique data-testid for robust selection."; } return ''; } const avoidNth = usesNth ? "💡 Tip: Avoid relying on '>> nth='; add a unique data-testid instead." : ''; // Verbose nth-selector guidance is rate-limited to one emit per session. // The short ⚠ warning still surfaces every call; the multi-line hint block // (duplicate-testid tip + nth-selector workaround) appears only on the // first multi-match of the session — it's reference material the agent // only needs once. let extraHints = ''; const { hasShownNthHint, markNthHintShown } = await import('../../toolHandler.js'); if (!hasShownNthHint()) { const duplicateWarning = this.getDuplicateTestIdWarning(selector, totalCount).trimEnd(); const nthHint = this.buildNthSelectorHint(selector, totalCount).trimEnd(); extraHints = [duplicateWarning, nthHint, avoidNth].filter(Boolean).join('\n'); if (duplicateWarning || nthHint) { markNthHintShown(); } } else if (avoidNth) { extraHints = avoidNth; } const baseMessage = preferredVisible ? `⚠ Found ${totalCount} elements matching "${selector}", using element ${elementIndex + 1} (first visible)` : `⚠ Found ${totalCount} elements matching "${selector}", using element ${elementIndex + 1}`; return extraHints ? `${baseMessage}\n${extraHints}` : baseMessage; } /** * Generate a warning message if the selector is a testid and there are duplicates * * @param selector The selector that was used * @param totalCount Number of matching elements * @returns Warning message with suggestion, or empty string if not applicable */ protected getDuplicateTestIdWarning(selector: string, totalCount: number): string { if (totalCount <= 1) { return ''; } // Check if this is a testid-style selector const isTestIdSelector = selector.startsWith('testid:') || selector.startsWith('data-test:') || selector.startsWith('data-cy:') || selector.match(/^\[data-(testid|test|cy)=/); if (isTestIdSelector) { return ( `💡 Tip: Test IDs should be unique. Consider making this test ID unique to avoid ambiguity.\n` + ` Primary fix: assign a unique data-testid to the intended element.\n` + ` Workaround: if you cannot change markup, you may use '>> nth=<index>' temporarily.\n\n` ); } // Suggest testid for non-testid selectors return ( `💡 Tip: Consider adding a unique data-testid attribute for more reliable selection.\n` + ` Primary fix: add data-testid and target it (e.g., testid:submit).\n` + ` Workaround: use '>> nth=<index>' only when you can't add test IDs.\n\n` ); } /** * Provide a hint for using >> nth= when multiple elements match a selector * * @param selector Original selector string * @param totalCount Total number of matches */ protected buildNthSelectorHint(selector: string, totalCount: number): string { const trimmed = selector.trim(); if (!trimmed || trimmed.includes('>> nth=')) { return ''; } const firstExample = `${trimmed} >> nth=0`; const lastIndex = Math.max(totalCount - 1, 1); const lastExample = `${trimmed} >> nth=${lastIndex}`; return ( `Primary fix: add a unique data-testid to the intended element and select it directly.\n` + `Workaround: Append ">> nth=<index>" to target a specific match when you cannot change markup.\n` + ` Example: ${firstExample} (first match)\n` + ` Or: ${lastExample} (last match)\n` + `Note: nth selectors are brittle and may break with layout/content changes.\n` + `Prefer unique data-testid attributes for long-term stability.` ); } /** * Describe matched elements in a compact, copyable format for disambiguation errors. * Shows: index, tag, trimmed text, nearest parent marker, and a suggested selector. * Suggests testid:VALUE when present; otherwise falls back to id=VALUE or original >> nth=i. */ protected async describeMatchedElements(locator: any, originalSelector: string, count: number): Promise<string> { const maxItems = Math.min(count, 5); const lines: string[] = []; for (let i = 0; i < maxItems; i++) { const nth = locator.nth(i); try { const info = await nth.evaluate((el: any) => { const tag = (el.tagName || '').toLowerCase(); let text = (el as HTMLElement).innerText || el.textContent || ''; text = (text || '').replace(/\s+/g, ' ').trim(); const testid = el.getAttribute?.('data-testid') || el.getAttribute?.('data-test') || el.getAttribute?.('data-cy') || null; const id = (el as HTMLElement).id || null; let parentLabel: string | null = null; let p: any = el.parentElement; while (p && !parentLabel) { const ptid = p.getAttribute?.('data-testid'); const ptest = p.getAttribute?.('data-test'); const pcy = p.getAttribute?.('data-cy'); const pid = (p as HTMLElement).id || null; if (ptid) parentLabel = `[data-testid="${ptid}"]`; else if (ptest) parentLabel = `[data-test="${ptest}"]`; else if (pcy) parentLabel = `[data-cy="${pcy}"]`; else if (pid) parentLabel = `#${pid}`; p = p.parentElement; } return { tag, text, testid, id, parentLabel }; }); const truncatedText = info.text && info.text.length > 80 ? `${info.text.slice(0, 77)}...` : info.text; let selectorSuggestion = `${originalSelector} >> nth=${i}`; let altSuggestion: string | undefined; if (info?.testid) { selectorSuggestion = `testid:${info.testid}`; altSuggestion = `${originalSelector} >> nth=${i}`; } else if (info?.id) { selectorSuggestion = `id=${info.id}`; altSuggestion = `${originalSelector} >> nth=${i}`; } const parts = [ `[${i}] <${info.tag}>${truncatedText ? ` "${truncatedText}"` : ''}`, info.parentLabel ? ` parent: ${info.parentLabel}` : undefined, ` selector: ${selectorSuggestion}`, altSuggestion ? ` alt: ${altSuggestion}` : undefined, ].filter(Boolean) as string[]; lines.push(parts.join('\n')); } catch { lines.push(`[${i}] (element)\n selector: ${originalSelector} >> nth=${i}`); } } if (count > maxItems) { lines.push(`… and ${count - maxItems} more matches (use >> nth=<index> to target).`); } return lines.join('\n'); } }