// Derived from browser_use https://github.com/browser-use/browser-use/blob/main/browser_use/dom/buildDomTree.js ( args = { doHighlightElements: true, focusHighlightIndex: -1, viewportExpansion: 0, debugMode: false, } ) => { const { doHighlightElements, focusHighlightIndex, viewportExpansion, debugMode } = args; let highlightIndex = 0; // Reset highlight index // Add timing stack to handle recursion const TIMING_STACK = { nodeProcessing: [], treeTraversal: [], highlighting: [], current: null }; function pushTiming(type) { TIMING_STACK[type] = TIMING_STACK[type] || []; TIMING_STACK[type].push(performance.now()); } function popTiming(type) { const start = TIMING_STACK[type].pop(); const duration = performance.now() - start; return duration; } // Only initialize performance tracking if in debug mode const PERF_METRICS = debugMode ? { buildDomTreeCalls: 0, timings: { buildDomTree: 0, highlightElement: 0, isInteractiveElement: 0, isElementVisible: 0, isTopElement: 0, isInExpandedViewport: 0, isTextNodeVisible: 0, getEffectiveScroll: 0, }, cacheMetrics: { boundingRectCacheHits: 0, boundingRectCacheMisses: 0, computedStyleCacheHits: 0, computedStyleCacheMisses: 0, getBoundingClientRectTime: 0, getComputedStyleTime: 0, boundingRectHitRate: 0, computedStyleHitRate: 0, overallHitRate: 0, }, nodeMetrics: { totalNodes: 0, processedNodes: 0, skippedNodes: 0, }, buildDomTreeBreakdown: { totalTime: 0, totalSelfTime: 0, buildDomTreeCalls: 0, domOperations: { getBoundingClientRect: 0, getComputedStyle: 0, }, domOperationCounts: { getBoundingClientRect: 0, getComputedStyle: 0, } } } : null; // Simple timing helper that only runs in debug mode function measureTime(fn) { if (!debugMode) return fn; return function (...args) { const start = performance.now(); const result = fn.apply(this, args); const duration = performance.now() - start; return result; }; } // Helper to measure DOM operations function measureDomOperation(operation, name) { if (!debugMode) return operation(); const start = performance.now(); const result = operation(); const duration = performance.now() - start; if (PERF_METRICS && name in PERF_METRICS.buildDomTreeBreakdown.domOperations) { PERF_METRICS.buildDomTreeBreakdown.domOperations[name] += duration; PERF_METRICS.buildDomTreeBreakdown.domOperationCounts[name]++; } return result; } // Add caching mechanisms at the top level const DOM_CACHE = { boundingRects: new WeakMap(), computedStyles: new WeakMap(), clearCache: () => { DOM_CACHE.boundingRects = new WeakMap(); DOM_CACHE.computedStyles = new WeakMap(); } }; // Cache helper functions function getCachedBoundingRect(element) { if (!element) return null; if (DOM_CACHE.boundingRects.has(element)) { if (debugMode && PERF_METRICS) { PERF_METRICS.cacheMetrics.boundingRectCacheHits++; } return DOM_CACHE.boundingRects.get(element); } if (debugMode && PERF_METRICS) { PERF_METRICS.cacheMetrics.boundingRectCacheMisses++; } let rect; if (debugMode) { const start = performance.now(); rect = element.getBoundingClientRect(); const duration = performance.now() - start; if (PERF_METRICS) { PERF_METRICS.buildDomTreeBreakdown.domOperations.getBoundingClientRect += duration; PERF_METRICS.buildDomTreeBreakdown.domOperationCounts.getBoundingClientRect++; } } else { rect = element.getBoundingClientRect(); } if (rect) { DOM_CACHE.boundingRects.set(element, rect); } return rect; } function getCachedComputedStyle(element) { if (!element) return null; if (DOM_CACHE.computedStyles.has(element)) { if (debugMode && PERF_METRICS) { PERF_METRICS.cacheMetrics.computedStyleCacheHits++; } return DOM_CACHE.computedStyles.get(element); } if (debugMode && PERF_METRICS) { PERF_METRICS.cacheMetrics.computedStyleCacheMisses++; } let style; if (debugMode) { const start = performance.now(); style = window.getComputedStyle(element); const duration = performance.now() - start; if (PERF_METRICS) { PERF_METRICS.buildDomTreeBreakdown.domOperations.getComputedStyle += duration; PERF_METRICS.buildDomTreeBreakdown.domOperationCounts.getComputedStyle++; } } else { style = window.getComputedStyle(element); } if (style) { DOM_CACHE.computedStyles.set(element, style); } return style; } /** * Hash map of DOM nodes indexed by their highlight index. * * @type {Object} */ const DOM_HASH_MAP = {}; const ID = { current: 0 }; const HIGHLIGHT_CONTAINER_ID = "playwright-highlight-container"; /** * Highlights an element in the DOM and returns the index of the next element. */ function highlightElement(element, index, parentIframe = null) { if (!element) return index; try { // Create or get highlight container let container = document.getElementById(HIGHLIGHT_CONTAINER_ID); if (!container) { container = document.createElement("div"); container.id = HIGHLIGHT_CONTAINER_ID; container.style.position = "fixed"; container.style.pointerEvents = "none"; container.style.top = "0"; container.style.left = "0"; container.style.width = "100%"; container.style.height = "100%"; container.style.zIndex = "2147483647"; document.body.appendChild(container); } // Get element position const rect = measureDomOperation( () => element.getBoundingClientRect(), 'getBoundingClientRect' ); if (!rect) return index; // Generate a color based on the index const colors = [ "#FF0000", "#00FF00", "#0000FF", "#FFA500", "#800080", "#008080", "#FF69B4", "#4B0082", "#FF4500", "#2E8B57", "#DC143C", "#4682B4", ]; const colorIndex = index % colors.length; const baseColor = colors[colorIndex]; const backgroundColor = baseColor + "1A"; // 10% opacity version of the color // Create highlight overlay const overlay = document.createElement("div"); overlay.style.position = "fixed"; overlay.style.border = `2px solid ${baseColor}`; overlay.style.backgroundColor = backgroundColor; overlay.style.pointerEvents = "none"; overlay.style.boxSizing = "border-box"; // Get element position let iframeOffset = { x: 0, y: 0 }; // If element is in an iframe, calculate iframe offset if (parentIframe) { const iframeRect = parentIframe.getBoundingClientRect(); iframeOffset.x = iframeRect.left; iframeOffset.y = iframeRect.top; } // Calculate position const top = rect.top + iframeOffset.y; const left = rect.left + iframeOffset.x; overlay.style.top = `${top}px`; overlay.style.left = `${left}px`; overlay.style.width = `${rect.width}px`; overlay.style.height = `${rect.height}px`; // Create and position label const label = document.createElement("div"); label.className = "playwright-highlight-label"; label.style.position = "fixed"; label.style.background = baseColor; label.style.color = "white"; label.style.padding = "1px 4px"; label.style.borderRadius = "4px"; label.style.fontSize = `${Math.min(12, Math.max(8, rect.height / 2))}px`; label.textContent = index; const labelWidth = 20; const labelHeight = 16; let labelTop = top + 2; let labelLeft = left + rect.width - labelWidth - 2; if (rect.width < labelWidth + 4 || rect.height < labelHeight + 4) { labelTop = top - labelHeight - 2; labelLeft = left + rect.width - labelWidth; } label.style.top = `${labelTop}px`; label.style.left = `${labelLeft}px`; // Add to container container.appendChild(overlay); container.appendChild(label); // Update positions on scroll const updatePositions = () => { const newRect = element.getBoundingClientRect(); let newIframeOffset = { x: 0, y: 0 }; if (parentIframe) { const iframeRect = parentIframe.getBoundingClientRect(); newIframeOffset.x = iframeRect.left; newIframeOffset.y = iframeRect.top; } const newTop = newRect.top + newIframeOffset.y; const newLeft = newRect.left + newIframeOffset.x; overlay.style.top = `${newTop}px`; overlay.style.left = `${newLeft}px`; overlay.style.width = `${newRect.width}px`; overlay.style.height = `${newRect.height}px`; let newLabelTop = newTop + 2; let newLabelLeft = newLeft + newRect.width - labelWidth - 2; if (newRect.width < labelWidth + 4 || newRect.height < labelHeight + 4) { newLabelTop = newTop - labelHeight - 2; newLabelLeft = newLeft + newRect.width - labelWidth; } label.style.top = `${newLabelTop}px`; label.style.left = `${newLabelLeft}px`; }; window.addEventListener('scroll', updatePositions); window.addEventListener('resize', updatePositions); return index + 1; } finally { popTiming('highlighting'); } } /** * Returns an XPath tree string for an element. */ function getXPathTree(element, stopAtBoundary = true) { const segments = []; let currentElement = element; while (currentElement && currentElement.nodeType === Node.ELEMENT_NODE) { // Stop if we hit a shadow root or iframe if ( stopAtBoundary && (currentElement.parentNode instanceof ShadowRoot || currentElement.parentNode instanceof HTMLIFrameElement) ) { break; } let index = 0; let sibling = currentElement.previousSibling; while (sibling) { if ( sibling.nodeType === Node.ELEMENT_NODE && sibling.nodeName === currentElement.nodeName ) { index++; } sibling = sibling.previousSibling; } const tagName = currentElement.nodeName.toLowerCase(); const xpathIndex = index > 0 ? `[${index + 1}]` : ""; segments.unshift(`${tagName}${xpathIndex}`); currentElement = currentElement.parentNode; } return segments.join("/"); } /** * Checks if a text node is visible. */ function isTextNodeVisible(textNode) { try { const range = document.createRange(); range.selectNodeContents(textNode); const rect = range.getBoundingClientRect(); // Simple size check if (rect.width === 0 || rect.height === 0) { return false; } // Simple viewport check without scroll calculations const isInViewport = !( rect.bottom < -viewportExpansion || rect.top > window.innerHeight + viewportExpansion || rect.right < -viewportExpansion || rect.left > window.innerWidth + viewportExpansion ); // Check parent visibility const parentElement = textNode.parentElement; if (!parentElement) return false; try { return isInViewport && parentElement.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true, }); } catch (e) { // Fallback if checkVisibility is not supported const style = window.getComputedStyle(parentElement); return isInViewport && style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; } } catch (e) { console.warn('Error checking text node visibility:', e); return false; } } // Helper function to check if element is accepted function isElementAccepted(element) { if (!element || !element.tagName) return false; // Always accept body and common container elements const alwaysAccept = new Set([ "body", "div", "main", "article", "section", "nav", "header", "footer" ]); const tagName = element.tagName.toLowerCase(); if (alwaysAccept.has(tagName)) return true; const leafElementDenyList = new Set([ "svg", "script", "style", "link", "meta", "noscript", "template", ]); return !leafElementDenyList.has(tagName); } /** * Checks if an element is visible. */ function isElementVisible(element) { const style = getCachedComputedStyle(element); return ( element.offsetWidth > 0 && element.offsetHeight > 0 && style.visibility !== "hidden" && style.display !== "none" ); } /** * Checks if an element is interactive. */ function isInteractiveElement(element) { if (!element || element.nodeType !== Node.ELEMENT_NODE) { return false; } // Special handling for cookie banner elements const isCookieBannerElement = (typeof element.closest === 'function') && ( element.closest('[id*="onetrust"]') || element.closest('[class*="onetrust"]') || element.closest('[data-nosnippet="true"]') || element.closest('[aria-label*="cookie"]') ); if (isCookieBannerElement) { // Check if it's a button or interactive element within the banner if ( element.tagName.toLowerCase() === 'button' || element.getAttribute('role') === 'button' || element.onclick || element.getAttribute('onclick') || (element.classList && ( element.classList.contains('ot-sdk-button') || element.classList.contains('accept-button') || element.classList.contains('reject-button') )) || element.getAttribute('aria-label')?.toLowerCase().includes('accept') || element.getAttribute('aria-label')?.toLowerCase().includes('reject') ) { return true; } } // Base interactive elements and roles const interactiveElements = new Set([ "a", "button", "details", "embed", "input", "menu", "menuitem", "object", "select", "textarea", "canvas", "summary", "dialog", "banner" ]); const interactiveRoles = new Set(['button-icon', 'dialog', 'button-text-icon-only', 'treeitem', 'alert', 'grid', 'progressbar', 'radio', 'checkbox', 'menuitem', 'option', 'switch', 'dropdown', 'scrollbar', 'combobox', 'a-button-text', 'button', 'region', 'textbox', 'tabpanel', 'tab', 'click', 'button-text', 'spinbutton', 'a-button-inner', 'link', 'menu', 'slider', 'listbox', 'a-dropdown-button', 'button-icon-only', 'searchbox', 'menuitemradio', 'tooltip', 'tree', 'menuitemcheckbox']); const tagName = element.tagName.toLowerCase(); const role = element.getAttribute("role"); const ariaRole = element.getAttribute("aria-role"); const tabIndex = element.getAttribute("tabindex"); // Add check for specific class const hasAddressInputClass = element.classList && ( element.classList.contains("address-input__container__input") || element.classList.contains("nav-btn") || element.classList.contains("pull-left") ); // Added enhancement to capture dropdown interactive elements if (element.classList && ( element.classList.contains('dropdown-toggle') || element.getAttribute('data-toggle') === 'dropdown' || element.getAttribute('aria-haspopup') === 'true' )) { return true; } // Basic role/attribute checks const hasInteractiveRole = hasAddressInputClass || interactiveElements.has(tagName) || interactiveRoles.has(role) || interactiveRoles.has(ariaRole) || (tabIndex !== null && tabIndex !== "-1" && element.parentElement?.tagName.toLowerCase() !== "body") || element.getAttribute("data-action") === "a-dropdown-select" || element.getAttribute("data-action") === "a-dropdown-button"; if (hasInteractiveRole) return true; // Additional checks for cookie banners and consent UI const isCookieBanner = element.id?.toLowerCase().includes('cookie') || element.id?.toLowerCase().includes('consent') || element.id?.toLowerCase().includes('notice') || (element.classList && ( element.classList.contains('otCenterRounded') || element.classList.contains('ot-sdk-container') )) || element.getAttribute('data-nosnippet') === 'true' || element.getAttribute('aria-label')?.toLowerCase().includes('cookie') || element.getAttribute('aria-label')?.toLowerCase().includes('consent') || (element.tagName.toLowerCase() === 'div' && ( element.id?.includes('onetrust') || (element.classList && ( element.classList.contains('onetrust') || element.classList.contains('cookie') || element.classList.contains('consent') )) )); if (isCookieBanner) return true; // Additional check for buttons in cookie banners const isInCookieBanner = typeof element.closest === 'function' && element.closest( '[id*="cookie"],[id*="consent"],[class*="cookie"],[class*="consent"],[id*="onetrust"]' ); if (isInCookieBanner && ( element.tagName.toLowerCase() === 'button' || element.getAttribute('role') === 'button' || (element.classList && element.classList.contains('button')) || element.onclick || element.getAttribute('onclick') )) { return true; } // Get computed style const style = window.getComputedStyle(element); // Check for event listeners const hasClickHandler = element.onclick !== null || element.getAttribute("onclick") !== null || element.hasAttribute("ng-click") || element.hasAttribute("@click") || element.hasAttribute("v-on:click"); // Helper function to safely get event listeners function getEventListeners(el) { try { return window.getEventListeners?.(el) || {}; } catch (e) { const listeners = {}; const eventTypes = [ "click", "mousedown", "mouseup", "touchstart", "touchend", "keydown", "keyup", "focus", "blur", ]; for (const type of eventTypes) { const handler = el[`on${type}`]; if (handler) { listeners[type] = [{ listener: handler, useCapture: false }]; } } return listeners; } } // Check for click-related events const listeners = getEventListeners(element); const hasClickListeners = listeners && (listeners.click?.length > 0 || listeners.mousedown?.length > 0 || listeners.mouseup?.length > 0 || listeners.touchstart?.length > 0 || listeners.touchend?.length > 0); // Check for ARIA properties const hasAriaProps = element.hasAttribute("aria-expanded") || element.hasAttribute("aria-pressed") || element.hasAttribute("aria-selected") || element.hasAttribute("aria-checked"); const isContentEditable = element.getAttribute("contenteditable") === "true" || element.isContentEditable || element.id === "tinymce" || element.classList.contains("mce-content-body") || (element.tagName.toLowerCase() === "body" && element.getAttribute("data-id")?.startsWith("mce_")); // Check if element is draggable const isDraggable = element.draggable || element.getAttribute("draggable") === "true"; return ( hasAriaProps || hasClickHandler || hasClickListeners || isDraggable || isContentEditable ); } /** * Checks if an element is the topmost element at its position. */ function isTopElement(element) { const rect = getCachedBoundingRect(element); // If element is not in viewport, consider it top const isInViewport = ( rect.left < window.innerWidth && rect.right > 0 && rect.top < window.innerHeight && rect.bottom > 0 ); if (!isInViewport) { return true; } // Find the correct document context and root element let doc = element.ownerDocument; // If we're in an iframe, elements are considered top by default if (doc !== window.document) { return true; } // For shadow DOM, we need to check within its own root context const shadowRoot = element.getRootNode(); if (shadowRoot instanceof ShadowRoot) { const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; try { const topEl = measureDomOperation( () => shadowRoot.elementFromPoint(centerX, centerY), 'elementFromPoint' ); if (!topEl) return false; let current = topEl; while (current && current !== shadowRoot) { if (current === element) return true; current = current.parentElement; } return false; } catch (e) { return true; } } // For elements in viewport, check if they're topmost const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; try { const topEl = document.elementFromPoint(centerX, centerY); if (!topEl) return false; let current = topEl; while (current && current !== document.documentElement) { if (current === element) return true; current = current.parentElement; } return false; } catch (e) { return true; } } /** * Checks if an element is within the expanded viewport. */ function isInExpandedViewport(element, viewportExpansion) { if (viewportExpansion === -1) { return true; } const rect = getCachedBoundingRect(element); // Simple viewport check without scroll calculations return !( rect.bottom < -viewportExpansion || rect.top > window.innerHeight + viewportExpansion || rect.right < -viewportExpansion || rect.left > window.innerWidth + viewportExpansion ); } // Add this new helper function function getEffectiveScroll(element) { let currentEl = element; let scrollX = 0; let scrollY = 0; return measureDomOperation(() => { while (currentEl && currentEl !== document.documentElement) { if (currentEl.scrollLeft || currentEl.scrollTop) { scrollX += currentEl.scrollLeft; scrollY += currentEl.scrollTop; } currentEl = currentEl.parentElement; } scrollX += window.scrollX; scrollY += window.scrollY; return { scrollX, scrollY }; }, 'scrollOperations'); } // Add these helper functions at the top level function isInteractiveCandidate(element) { if (!element || element.nodeType !== Node.ELEMENT_NODE) return false; const tagName = element.tagName.toLowerCase(); // Fast-path for common interactive elements const interactiveElements = new Set([ "a", "button", "input", "select", "textarea", "details", "summary" ]); if (interactiveElements.has(tagName)) return true; // Quick attribute checks without getting full lists const hasQuickInteractiveAttr = element.hasAttribute("onclick") || element.hasAttribute("role") || element.hasAttribute("tabindex") || element.hasAttribute("aria-") || element.hasAttribute("data-action"); return hasQuickInteractiveAttr; } function quickVisibilityCheck(element) { // Fast initial check before expensive getComputedStyle return element.offsetWidth > 0 && element.offsetHeight > 0 && !element.hasAttribute("hidden") && element.style.display !== "none" && element.style.visibility !== "hidden"; } /** * Creates a node data object for a given node and its descendants. */ function buildDomTree(node, parentIframe = null) { if (debugMode) PERF_METRICS.nodeMetrics.totalNodes++; if (!node || node.id === HIGHLIGHT_CONTAINER_ID) { if (debugMode) PERF_METRICS.nodeMetrics.skippedNodes++; return null; } // Special handling for root node (body) if (node === document.body) { const nodeData = { tagName: 'body', attributes: {}, xpath: '/body', children: [], }; // Process children of body for (const child of node.childNodes) { const domElement = buildDomTree(child, parentIframe); if (domElement) nodeData.children.push(domElement); } const id = `${ID.current++}`; DOM_HASH_MAP[id] = nodeData; if (debugMode) PERF_METRICS.nodeMetrics.processedNodes++; return id; } // Early bailout for non-element nodes except text if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.TEXT_NODE) { if (debugMode) PERF_METRICS.nodeMetrics.skippedNodes++; return null; } // Process text nodes if (node.nodeType === Node.TEXT_NODE) { const textContent = node.textContent.trim(); if (!textContent) { if (debugMode) PERF_METRICS.nodeMetrics.skippedNodes++; return null; } // Only check visibility for text nodes that might be visible const parentElement = node.parentElement; if (!parentElement || parentElement.tagName.toLowerCase() === 'script') { if (debugMode) PERF_METRICS.nodeMetrics.skippedNodes++; return null; } const id = `${ID.current++}`; DOM_HASH_MAP[id] = { type: "TEXT_NODE", text: textContent, isVisible: isTextNodeVisible(node), }; if (debugMode) PERF_METRICS.nodeMetrics.processedNodes++; return id; } // Quick checks for element nodes if (node.nodeType === Node.ELEMENT_NODE && !isElementAccepted(node)) { if (debugMode) PERF_METRICS.nodeMetrics.skippedNodes++; return null; } // Early viewport check - only filter out elements clearly outside viewport if (viewportExpansion !== -1) { const rect = getCachedBoundingRect(node); const style = getCachedComputedStyle(node); // Skip viewport check for fixed/sticky elements as they may appear anywhere const isFixedOrSticky = style && (style.position === 'fixed' || style.position === 'sticky'); // Check if element has actual dimensions const hasSize = node.offsetWidth > 0 || node.offsetHeight > 0; if (!rect || (!isFixedOrSticky && !hasSize && ( rect.bottom < -viewportExpansion || rect.top > window.innerHeight + viewportExpansion || rect.right < -viewportExpansion || rect.left > window.innerWidth + viewportExpansion ))) { if (debugMode) PERF_METRICS.nodeMetrics.skippedNodes++; return null; } } // Process element node const nodeData = { tagName: node.tagName.toLowerCase(), attributes: {}, xpath: getXPathTree(node, true), children: [], }; // Get attributes for interactive elements or potential text containers if (isInteractiveCandidate(node) || node.tagName.toLowerCase() === 'iframe' || node.tagName.toLowerCase() === 'body') { const attributeNames = node.getAttributeNames?.() || []; for (const name of attributeNames) { nodeData.attributes[name] = node.getAttribute(name); } } // if (isInteractiveCandidate(node)) { // Check interactivity if (node.nodeType === Node.ELEMENT_NODE) { nodeData.isVisible = isElementVisible(node); if (nodeData.isVisible) { nodeData.isTopElement = isTopElement(node); if (nodeData.isTopElement) { nodeData.isInteractive = isInteractiveElement(node); if (nodeData.isInteractive) { nodeData.isInViewport = true; nodeData.highlightIndex = highlightIndex++; if (doHighlightElements) { if (focusHighlightIndex >= 0) { if (focusHighlightIndex === nodeData.highlightIndex) { highlightElement(node, nodeData.highlightIndex, parentIframe); } } else { highlightElement(node, nodeData.highlightIndex, parentIframe); } } } } } } // Process children, with special handling for iframes and rich text editors if (node.tagName) { const tagName = node.tagName.toLowerCase(); // Handle iframes if (tagName === "iframe") { try { const iframeDoc = node.contentDocument || node.contentWindow?.document; if (iframeDoc) { for (const child of iframeDoc.childNodes) { const domElement = buildDomTree(child, node); if (domElement) nodeData.children.push(domElement); } } } catch (e) { console.warn("Unable to access iframe:", e); } } // Handle rich text editors and contenteditable elements else if ( node.isContentEditable || node.getAttribute("contenteditable") === "true" || node.id === "tinymce" || node.classList.contains("mce-content-body") || (tagName === "body" && node.getAttribute("data-id")?.startsWith("mce_")) ) { // Process all child nodes to capture formatted text for (const child of node.childNodes) { const domElement = buildDomTree(child, parentIframe); if (domElement) nodeData.children.push(domElement); } } // Handle shadow DOM else if (node.shadowRoot) { nodeData.shadowRoot = true; for (const child of node.shadowRoot.childNodes) { const domElement = buildDomTree(child, parentIframe); if (domElement) nodeData.children.push(domElement); } } // Handle regular elements else { for (const child of node.childNodes) { const domElement = buildDomTree(child, parentIframe); if (domElement) nodeData.children.push(domElement); } } } // Skip empty anchor tags if (nodeData.tagName === 'a' && nodeData.children.length === 0 && !nodeData.attributes.href) { if (debugMode) PERF_METRICS.nodeMetrics.skippedNodes++; return null; } const id = `${ID.current++}`; DOM_HASH_MAP[id] = nodeData; if (debugMode) PERF_METRICS.nodeMetrics.processedNodes++; return id; } // After all functions are defined, wrap them with performance measurement // Remove buildDomTree from here as we measure it separately highlightElement = measureTime(highlightElement); isInteractiveElement = measureTime(isInteractiveElement); isElementVisible = measureTime(isElementVisible); isTopElement = measureTime(isTopElement); isInExpandedViewport = measureTime(isInExpandedViewport); isTextNodeVisible = measureTime(isTextNodeVisible); getEffectiveScroll = measureTime(getEffectiveScroll); const rootId = buildDomTree(document.body); // Clear the cache before starting DOM_CACHE.clearCache(); // Only process metrics in debug mode if (debugMode && PERF_METRICS) { // Convert timings to seconds and add useful derived metrics Object.keys(PERF_METRICS.timings).forEach(key => { PERF_METRICS.timings[key] = PERF_METRICS.timings[key] / 1000; }); Object.keys(PERF_METRICS.buildDomTreeBreakdown).forEach(key => { if (typeof PERF_METRICS.buildDomTreeBreakdown[key] === 'number') { PERF_METRICS.buildDomTreeBreakdown[key] = PERF_METRICS.buildDomTreeBreakdown[key] / 1000; } }); // Add some useful derived metrics if (PERF_METRICS.buildDomTreeBreakdown.buildDomTreeCalls > 0) { PERF_METRICS.buildDomTreeBreakdown.averageTimePerNode = PERF_METRICS.buildDomTreeBreakdown.totalTime / PERF_METRICS.buildDomTreeBreakdown.buildDomTreeCalls; } PERF_METRICS.buildDomTreeBreakdown.timeInChildCalls = PERF_METRICS.buildDomTreeBreakdown.totalTime - PERF_METRICS.buildDomTreeBreakdown.totalSelfTime; // Add average time per operation to the metrics Object.keys(PERF_METRICS.buildDomTreeBreakdown.domOperations).forEach(op => { const time = PERF_METRICS.buildDomTreeBreakdown.domOperations[op]; const count = PERF_METRICS.buildDomTreeBreakdown.domOperationCounts[op]; if (count > 0) { PERF_METRICS.buildDomTreeBreakdown.domOperations[`${op}Average`] = time / count; } }); // Calculate cache hit rates const boundingRectTotal = PERF_METRICS.cacheMetrics.boundingRectCacheHits + PERF_METRICS.cacheMetrics.boundingRectCacheMisses; const computedStyleTotal = PERF_METRICS.cacheMetrics.computedStyleCacheHits + PERF_METRICS.cacheMetrics.computedStyleCacheMisses; if (boundingRectTotal > 0) { PERF_METRICS.cacheMetrics.boundingRectHitRate = PERF_METRICS.cacheMetrics.boundingRectCacheHits / boundingRectTotal; } if (computedStyleTotal > 0) { PERF_METRICS.cacheMetrics.computedStyleHitRate = PERF_METRICS.cacheMetrics.computedStyleCacheHits / computedStyleTotal; } if ((boundingRectTotal + computedStyleTotal) > 0) { PERF_METRICS.cacheMetrics.overallHitRate = (PERF_METRICS.cacheMetrics.boundingRectCacheHits + PERF_METRICS.cacheMetrics.computedStyleCacheHits) / (boundingRectTotal + computedStyleTotal); } } return debugMode ? { rootId, map: DOM_HASH_MAP, perfMetrics: PERF_METRICS } : { rootId, map: DOM_HASH_MAP }; };