Skip to main content
Glama
base.ts19.8 kB
import type { Browser, 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. * @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; } /** * 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'; const nthHint = ''.trimEnd(); const warning = ''.trimEnd(); let message = `Selector "${selector}" matched ${count} elements. Please use a more specific selector.`; if (nthHint) { message += `\n${nthHint}`; } if (warning) { message += `\n${warning}`; } { 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.`, ]; const matchesDetails = await this.describeMatchedElements(locator, selector, count); message += `\n${guidance.join('\n')}\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 formatElementSelectionInfo( selector: string, elementIndex: number, totalCount: number, preferredVisible: boolean = true ): 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 duplicateWarning = this.getDuplicateTestIdWarning(selector, totalCount).trimEnd(); const nthHint = this.buildNthSelectorHint(selector, totalCount).trimEnd(); const avoidNth = usesNth ? "💡 Tip: Avoid relying on '>> nth='; add a unique data-testid instead." : ''; const extraHints = [duplicateWarning, nthHint, avoidNth].filter(Boolean).join('\n'); 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'); } }

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