check_visibility
Check if an element is visible to the user. Returns viewport intersection, clipping status, and scrolling requirements to debug interaction failures.
Instructions
Check if an element is visible to the user. CRITICAL for debugging click/interaction failures. Returns detailed visibility information including viewport intersection, clipping by overflow:hidden, and whether element needs scrolling. Supports testid shortcuts (e.g., 'testid:submit-button').
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| selector | Yes | CSS selector, text selector, or testid shorthand (e.g., 'testid:login-button', '#submit', 'text=Click here') |
Implementation Reference
- The execute() method (line 54-302) contains the core tool logic: it takes a selector, resolves the element via Playwright locator, evaluates visibility (viewport intersection, CSS properties, clipping by overflow:hidden, coverage by other elements, interactability), and returns a detailed formatted report with status, issues, and suggestions.
async execute(args: any, context: ToolContext): Promise<ToolResponse> { return this.safeExecute(context, async (page) => { const locator = await this.createScopedLocator(page, args.selector); try { // Use standard element selection with visibility preference const { element, elementIndex, totalCount } = await this.selectPreferredLocator(locator, { originalSelector: args.selector, }); // Format selection warning if multiple elements matched const multipleMatchWarning = await this.formatElementSelectionInfo( args.selector, elementIndex, totalCount ); // Get basic visibility (Playwright's isVisible) const isVisible = await element.isVisible(); // Evaluate detailed visibility information in browser context const visibilityData = await element.evaluate((element) => { const rect = element.getBoundingClientRect(); const viewportHeight = window.innerHeight; const viewportWidth = window.innerWidth; // Calculate viewport intersection ratio const visibleTop = Math.max(0, rect.top); const visibleBottom = Math.min(viewportHeight, rect.bottom); const visibleLeft = Math.max(0, rect.left); const visibleRight = Math.min(viewportWidth, rect.right); const visibleHeight = Math.max(0, visibleBottom - visibleTop); const visibleWidth = Math.max(0, visibleRight - visibleLeft); const visibleArea = visibleHeight * visibleWidth; const totalArea = rect.height * rect.width; const viewportRatio = totalArea > 0 ? visibleArea / totalArea : 0; // Check if element is in viewport const isInViewport = viewportRatio > 0; // Get computed styles const styles = window.getComputedStyle(element); const opacity = parseFloat(styles.opacity); const display = styles.display; const visibility = styles.visibility; // Check if clipped by overflow:hidden let isClipped = false; let parent = element.parentElement; while (parent) { const parentStyle = window.getComputedStyle(parent); if ( parentStyle.overflow === 'hidden' || parentStyle.overflowX === 'hidden' || parentStyle.overflowY === 'hidden' ) { const parentRect = parent.getBoundingClientRect(); // Check if element is outside parent bounds if ( rect.right < parentRect.left || rect.left > parentRect.right || rect.bottom < parentRect.top || rect.top > parentRect.bottom ) { isClipped = true; break; } } parent = parent.parentElement; } // Check if covered by another element (check center point and corners) const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const topElement = document.elementFromPoint(centerX, centerY); const isCovered = topElement !== element && !element.contains(topElement); // Get covering element info if covered let coveringElementInfo = ''; let coveragePercent = 0; if (isCovered && topElement) { const coveringTagName = topElement.tagName.toLowerCase(); const coveringTestId = topElement.getAttribute('data-testid'); const coveringId = topElement.id ? `#${topElement.id}` : ''; const coveringClasses = topElement.className && typeof topElement.className === 'string' ? `.${(topElement.className as string).split(' ').filter((c: string) => c).slice(0, 2).join('.')}` : ''; const coveringStyles = window.getComputedStyle(topElement); const zIndex = coveringStyles.zIndex; let descriptor = `<${coveringTagName}`; if (coveringTestId) descriptor += ` data-testid="${coveringTestId}"`; else if (coveringId) descriptor += coveringId; else if (coveringClasses) descriptor += coveringClasses; descriptor += `> (z-index: ${zIndex})`; coveringElementInfo = descriptor; // Calculate approximate coverage by checking multiple points const samplePoints = [ [centerX, centerY], [rect.left + rect.width * 0.25, rect.top + rect.height * 0.25], [rect.left + rect.width * 0.75, rect.top + rect.height * 0.25], [rect.left + rect.width * 0.25, rect.top + rect.height * 0.75], [rect.left + rect.width * 0.75, rect.top + rect.height * 0.75], ]; let coveredPoints = 0; samplePoints.forEach(([x, y]) => { const pointElement = document.elementFromPoint(x, y); if (pointElement !== element && !element.contains(pointElement)) { coveredPoints++; } }); coveragePercent = Math.round((coveredPoints / samplePoints.length) * 100); } // Check interactability const computedStyles = window.getComputedStyle(element); const pointerEvents = computedStyles.pointerEvents; const isDisabled = (element as HTMLInputElement | HTMLButtonElement | HTMLTextAreaElement | HTMLSelectElement).disabled || false; const isReadonly = (element as HTMLInputElement | HTMLTextAreaElement).readOnly || false; const ariaDisabled = element.getAttribute('aria-disabled') === 'true'; return { viewportRatio, isInViewport, opacity, display, visibility, isClipped, isCovered, coveringElementInfo, coveragePercent, pointerEvents, isDisabled, isReadonly, ariaDisabled, }; }); // Determine if scroll is needed const needsScroll = isVisible && !visibilityData.isInViewport; // Get element tag name for output const tagInfo = await element.evaluate((el) => { const tagName = el.tagName.toLowerCase(); const testId = el.getAttribute('data-testid') || el.getAttribute('data-test') || el.getAttribute('data-cy'); const id = el.id ? `#${el.id}` : ''; const classes = el.className && typeof el.className === 'string' ? `.${el.className.split(' ').filter(c => c).join('.')}` : ''; let descriptor = `<${tagName}`; if (testId) descriptor += ` data-testid="${testId}"`; else if (id) descriptor += id; else if (classes) descriptor += classes; descriptor += '>'; return descriptor; }); // Build compact text format const viewportPercent = Math.round(visibilityData.viewportRatio * 100); let output = multipleMatchWarning + `Visibility: ${tagInfo}\n\n`; // Status line const visibleSymbol = isVisible ? '✓' : '✗'; const viewportSymbol = visibilityData.isInViewport ? '✓' : '✗'; const viewportText = visibilityData.isInViewport ? `in viewport${viewportPercent < 100 ? ` (${viewportPercent}% visible)` : ''}` : `not in viewport${viewportPercent > 0 ? ` (${viewportPercent}% visible)` : ''}`; output += `${visibleSymbol} ${isVisible ? 'visible' : 'hidden'}, ${viewportSymbol} ${viewportText}\n`; // CSS properties output += `opacity: ${visibilityData.opacity}, display: ${visibilityData.display}, visibility: ${visibilityData.visibility}\n`; // Interactability section const interactabilityIssues: string[] = []; if (visibilityData.isDisabled) { interactabilityIssues.push('disabled'); } if (visibilityData.isReadonly) { interactabilityIssues.push('readonly'); } if (visibilityData.ariaDisabled) { interactabilityIssues.push('aria-disabled'); } if (visibilityData.pointerEvents === 'none') { interactabilityIssues.push('pointer-events: none'); } if (interactabilityIssues.length > 0) { output += `⚠ interactability: ${interactabilityIssues.join(', ')}\n`; } // Issues section const issues: string[] = []; if (visibilityData.isClipped) { issues.push(' ✗ clipped by parent overflow:hidden'); } if (visibilityData.isCovered) { const coverageInfo = visibilityData.coveragePercent > 0 ? ` (~${visibilityData.coveragePercent}% covered)` : ''; issues.push(` ✗ covered by another element${coverageInfo}`); if (visibilityData.coveringElementInfo) { issues.push(` Covering: ${visibilityData.coveringElementInfo}`); } } if (needsScroll) { issues.push(' ⚠ needs scroll to bring into view'); } if (issues.length > 0) { output += '\nIssues:\n'; output += issues.join('\n') + '\n'; } // Suggestions const suggestions: string[] = []; if (needsScroll) { suggestions.push('→ Call scroll_to_element before clicking'); } if (visibilityData.isCovered) { suggestions.push('→ Element may be behind modal, overlay, or fixed header'); } if (interactabilityIssues.length > 0) { suggestions.push('→ Element cannot be interacted with in current state'); } if (suggestions.length > 0) { output += '\n' + suggestions.join('\n'); } // Suggest inspect_ancestors if element is clipped if (visibilityData.isClipped) { output += '\n\n💡 Element clipped by parent. Find the clipping container:'; output += `\n inspect_ancestors({ selector: "${args.selector}" })`; } return createSuccessResponse(output.trim()); } catch (error) { return createErrorResponse(`Failed to check visibility: ${(error as Error).message}`); } }); } } - The inputSchema (lines 38-47) defines the tool's JSON schema: a required 'selector' string parameter (CSS selector, text selector, or testid shorthand).
inputSchema: { type: "object", properties: { selector: { type: "string", description: "CSS selector, text selector, or testid shorthand (e.g., 'testid:login-button', '#submit', 'text=Click here')" } }, required: ["selector"], }, }; - src/tools/browser/register.ts:83-83 (registration)CheckVisibilityTool is imported at line 32 and registered in the BROWSER_TOOL_CLASSES array at line 83, making it available as a browser tool.
CheckVisibilityTool, - src/tools/browser/base.ts:8-761 (helper)BrowserToolBase (lines 8-761) is the parent class providing helper methods used by CheckVisibilityTool: safeExecute (error handling wrapper), createScopedLocator (dialog:: prefix resolution), selectPreferredLocator (element disambiguation), formatElementSelectionInfo, normalizeSelector, and others.
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'); } } - The CheckVisibilityTool class (lines 8-303) extends BrowserToolBase. The static getMetadata() method (lines 9-48) defines the tool name 'check_visibility', description, annotations, examples, and input schema.
export class CheckVisibilityTool extends BrowserToolBase { static getMetadata(sessionConfig?: SessionConfig): ToolMetadata { return { name: "check_visibility", description: "Check if an element is visible to the user. CRITICAL for debugging click/interaction failures. Returns detailed visibility information including viewport intersection, clipping by overflow:hidden, and whether element needs scrolling. Supports testid shortcuts (e.g., 'testid:submit-button').", annotations: ANNOTATIONS.readOnly, priority: 4, outputs: [ "Header: Visibility: <tag id/class/testid>",