Skip to main content
Glama
check_visibility.ts13.1 kB
import { BrowserToolBase } from '../base.js'; import { ToolContext, ToolResponse, ToolMetadata, SessionConfig, createSuccessResponse, createErrorResponse } from '../../common/types.js'; /** * Tool for checking element visibility with detailed diagnostics * Addresses the #1 debugging pain point: "Why won't it click?" */ 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').", priority: 4, outputs: [ "Header: Visibility: <tag id/class/testid>", "Status line: ✓ visible/✗ hidden, ✓/✗ in viewport with % visible", "CSS: opacity, display, visibility", "Optional interactability issues: disabled, readonly, aria-disabled, pointer-events:none", "Optional Issues block: clipped by parent overflow, covered by element (with descriptor and ~coverage%), needs scroll", "Optional Suggestions: scroll_to_element, modal/overlay hint, interaction state note", "Optional tip to run inspect_ancestors when clipping is detected", ], examples: [ "check_visibility({ selector: 'testid:submit' })", "check_visibility({ selector: '#login button' })", ], exampleOutputs: [ { call: "check_visibility({ selector: 'testid:submit' })", output: `Visibility: <button data-testid=\"submit\">\n\n✓ visible, ✓ in viewport\nopacity: 1, display: inline-block, visibility: visible` }, { call: "check_visibility({ selector: '#hero-cta' })", output: `Visibility: <a #hero-cta>\n\n✗ hidden, ✗ not in viewport (45% visible)\nopacity: 1, display: block, visibility: visible\n\nIssues:\n ✗ covered by another element (~60% covered)\n Covering: <div .modal-backdrop> (z-index: 9999)\n ⚠ needs scroll to bring into view\n\n→ Call scroll_to_element before clicking\n→ Element may be behind modal, overlay, or fixed header` } ], 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"], }, }; } /** * Execute the element visibility tool */ async execute(args: any, context: ToolContext): Promise<ToolResponse> { return this.safeExecute(context, async (page) => { const selector = this.normalizeSelector(args.selector); const locator = page.locator(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 = 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}`); } }); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/antonzherdev/mcp-web-inspector'

If you have feedback or need assistance with the MCP directory API, please join our Discord server