UNPKG

browser-debugger-cli

Version:

DevTools telemetry in your terminal. For humans and agents. Direct WebSocket to Chrome's debugging port.

242 lines 10.4 kB
/** * DOM element resolver for index and selector-based access. * * Provides centralized resolution of DOM elements from: * - Numeric indices (referencing cached query results) * - CSS selectors (used directly) * * Handles cache validation, staleness detection, and automatic refresh. * When cache is stale due to navigation, automatically re-runs the original * query to provide seamless "just works" experience. * * @example * ```typescript * const resolver = DomElementResolver.getInstance(); * * // Resolve index from cached query * const target = await resolver.resolve('0'); * // { success: true, selector: '.cached-selector', index: 1 } * * // Resolve CSS selector directly * const target = await resolver.resolve('button.submit'); * // { success: true, selector: 'button.submit' } * * // Get nodeId for cached index (throws if invalid) * const nodeId = await resolver.getNodeIdForIndex(0); * ``` */ import { QueryCacheManager } from '../../session/QueryCacheManager.js'; import { CommandError } from '../../ui/errors/index.js'; import { createLogger } from '../../ui/logging/index.js'; import { elementAtIndexNotFoundError, indexOutOfRangeError } from '../../ui/messages/errors.js'; import { EXIT_CODES } from '../../utils/exitCodes.js'; const log = createLogger('dom'); /** * Singleton resolver for DOM element access patterns. * * Centralizes element resolution with cache validation, automatic refresh, * and consistent error handling. */ export class DomElementResolver { static instance = null; cacheManager; /** * Create a new resolver instance. * * @param cacheManager - Query cache manager (defaults to singleton) */ constructor(cacheManager) { this.cacheManager = cacheManager ?? QueryCacheManager.getInstance(); } /** * Refresh stale cache by re-running the original query. * * Called automatically when cache validation fails due to navigation. * Re-queries using the stored selector and updates the cache with fresh results. * * @param selector - Original CSS selector from stale cache * @returns Fresh query result */ async refreshCache(selector) { log.debug(`Cache stale, auto-refreshing query "${selector}"`); const { queryDOMElements } = await import('../../commands/dom/helpers.js'); const result = await queryDOMElements(selector); const navigationId = await this.cacheManager.getCurrentNavigationId(); const resultWithNavId = { ...result, ...(navigationId !== null && { navigationId }), }; await this.cacheManager.set(resultWithNavId); this.cacheManager.invalidateNavigationCache(); log.debug(`Cache refreshed: found ${result.count} elements`); } /** * Get the singleton instance. * * @returns DomElementResolver instance */ static getInstance() { DomElementResolver.instance ??= new DomElementResolver(); return DomElementResolver.instance; } /** * Reset the singleton instance (for testing). */ static resetInstance() { DomElementResolver.instance = null; } /** * Resolve a selectorOrIndex argument to an element target. * * Handles the common pattern of accepting either: * - A CSS selector string (used directly) * - A numeric index (resolved from cached query results) * * Automatically refreshes stale cache by re-running the original query. * This provides a "just works" experience where navigation doesn't break * index-based access. * * @param selectorOrIndex - CSS selector or numeric index from query results * @param explicitIndex - Optional explicit --index flag value (0-based) * @returns Resolution result with selector and optional index * * @example * ```typescript * const target = await resolver.resolve('button'); * // { success: true, selector: 'button' } * * const target = await resolver.resolve('0'); * // { success: true, selector: '.cached-selector', index: 1 } * ``` */ async resolve(selectorOrIndex, explicitIndex) { const isNumericIndex = /^\d+$/.test(selectorOrIndex); if (isNumericIndex) { let validation = await this.cacheManager.validate(); // Auto-refresh: if cache is stale but has selector, re-run query if (!validation.valid && validation.cache?.selector) { await this.refreshCache(validation.cache.selector); validation = await this.cacheManager.validate(); } if (!validation.valid || !validation.cache) { return { success: false, error: validation.error ?? 'No cached query results found', exitCode: EXIT_CODES.INVALID_ARGUMENTS, suggestion: validation.suggestion, }; } const cachedQuery = validation.cache; const index = parseInt(selectorOrIndex, 10); // Find node by index property (supports non-sequential indices from form discovery) const targetNode = cachedQuery.nodes.find((n) => n.index === index); if (!targetNode) { // Fall back to array-position lookup for sequential indices (standard queries) if (index >= 0 && index < cachedQuery.nodes.length) { const nodeByPosition = cachedQuery.nodes[index]; if (nodeByPosition) { const fallbackSelector = nodeByPosition.preview ?? cachedQuery.selector; const fallbackIndex = nodeByPosition.preview ? undefined : index + 1; return { success: true, selector: fallbackSelector, index: fallbackIndex, }; } } const validIndices = cachedQuery.nodes.map((n) => n.index).sort((a, b) => a - b); return { success: false, error: `Index ${index} not found in cached results (query "${cachedQuery.selector}")`, exitCode: EXIT_CODES.STALE_CACHE, suggestion: validIndices.length === 0 ? `No elements found. The selector "${cachedQuery.selector}" may no longer match any elements.` : `Valid indices: ${validIndices.join(', ')}`, }; } // Use node's preview selector if available (from form discovery), otherwise original selector const resolvedSelector = targetNode.preview ?? cachedQuery.selector; // If using preview selector (unique element), don't pass index // Index is only needed when original selector matches multiple elements const resolvedIndex = targetNode.preview ? undefined : index + 1; return { success: true, selector: resolvedSelector, index: resolvedIndex, }; } return { success: true, selector: selectorOrIndex, index: explicitIndex, }; } /** * Get nodeId for a cached index. * * Automatically refreshes stale cache by re-running the original query. * Throws CommandError only if refresh fails or index is out of range after refresh. * * @param index - Zero-based index from query results * @returns Node with nodeId from cache * @throws CommandError if cache missing, index out of range after refresh, or node not found * * @example * ```typescript * const node = await resolver.getNodeIdForIndex(0); * console.log(node.nodeId); // CDP node ID * ``` */ async getNodeIdForIndex(index) { let validation = await this.cacheManager.validate(); // Auto-refresh: if cache is stale but has selector, re-run query if (!validation.valid && validation.cache?.selector) { await this.refreshCache(validation.cache.selector); validation = await this.cacheManager.validate(); } if (!validation.valid || !validation.cache) { throw new CommandError(validation.error ?? 'No cached query results found', validation.suggestion ? { suggestion: validation.suggestion } : {}, EXIT_CODES.INVALID_ARGUMENTS); } const cachedQuery = validation.cache; if (index < 0 || index >= cachedQuery.nodes.length) { const err = indexOutOfRangeError(index, cachedQuery.nodes.length - 1); throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.STALE_CACHE); } const targetNode = cachedQuery.nodes[index]; if (!targetNode) { const err = elementAtIndexNotFoundError(index, cachedQuery.selector); throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.RESOURCE_NOT_FOUND); } return targetNode; } /** * Get the count of cached elements. * * Automatically refreshes stale cache by re-running the original query. * * @returns Number of cached elements * @throws CommandError if cache is missing or refresh fails */ async getElementCount() { let validation = await this.cacheManager.validate(); // Auto-refresh: if cache is stale but has selector, re-run query if (!validation.valid && validation.cache?.selector) { await this.refreshCache(validation.cache.selector); validation = await this.cacheManager.validate(); } if (!validation.valid || !validation.cache) { throw new CommandError(validation.error ?? 'No cached query results found', validation.suggestion ? { suggestion: validation.suggestion } : {}, EXIT_CODES.INVALID_ARGUMENTS); } return validation.cache.nodes.length; } /** * Check if the argument is a numeric index. * * @param selectorOrIndex - String to check * @returns True if the string is a numeric index */ isNumericIndex(selectorOrIndex) { return /^\d+$/.test(selectorOrIndex); } } //# sourceMappingURL=DomElementResolver.js.map