Skip to main content
Glama

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

TableJSON Schema
NameRequiredDescriptionDefault
selectorYesCSS 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"],
        },
      };
    }
  • 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);
      }
    }
  • 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,
  • 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');
      }
    }
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already indicate non-read-only behavior, but the description does not add details about side effects (e.g., triggering hover CSS states). Adequate but minimal extra value.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Extremely concise and to the point, no unnecessary words.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a simple one-parameter tool with annotations, the description is adequate though it could optionally mention that hovering triggers CSS pseudo-classes.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100% and the parameter description is sufficient; the tool description does not add additional meaning beyond the schema.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the action ('Hover') and the resource ('an element on the page'), distinguishing it from sibling tools like click or drag.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines2/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

No guidance on when to use hover versus alternatives such as click, nor any exclusions or prerequisites.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

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