@probolabs/playwright 1.0.6 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +227 -227
- package/dist/fixtures.js.map +1 -1
- package/dist/index.d.ts +112 -17
- package/dist/index.js +690 -209
- package/dist/index.js.map +1 -1
- package/loaded_extensions/README.md +23 -23
- package/package.json +12 -11
- package/dist/types/actions.d.ts.map +0 -1
- package/dist/types/fixtures.d.ts.map +0 -1
- package/dist/types/highlight.d.ts.map +0 -1
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/nav-tracker.d.ts.map +0 -1
- package/dist/types/probo-logger.d.ts.map +0 -1
- package/dist/types/replay-utils.d.ts.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const highlighterCode = "(function (global, factory) {\n typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :\n typeof define === 'function' && define.amd ? define(['exports'], factory) :\n (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ProboLabs = {}));\n})(this, (function (exports) { 'use strict';\n\n const ElementTag = {\r\n CLICKABLE: \"CLICKABLE\", // button, link, toggle switch, checkbox, radio, dropdowns, clickable divs\r\n FILLABLE: \"FILLABLE\", // input, textarea content_editable, date picker??\r\n SELECTABLE: \"SELECTABLE\", // select\r\n NON_INTERACTIVE_ELEMENT: 'NON_INTERACTIVE_ELEMENT',\r\n };\r\n\r\n class ElementInfo {\r\n constructor(element, index, {tag, type, text, html, xpath, css_selector, bounding_box, iframe_selector, short_css_selector, short_iframe_selector}) {\r\n this.index = index.toString();\r\n this.tag = tag;\r\n this.type = type;\r\n this.text = text;\r\n this.html = html;\r\n this.xpath = xpath;\r\n this.css_selector = css_selector;\r\n this.bounding_box = bounding_box;\r\n this.iframe_selector = iframe_selector;\r\n this.element = element;\r\n this.depth = -1;\r\n this.short_css_selector = short_css_selector;\r\n this.short_iframe_selector = short_iframe_selector;\r\n }\r\n\r\n getSelector() {\r\n return this.xpath ? this.xpath : this.css_selector;\r\n }\r\n\r\n getDepth() {\r\n if (this.depth >= 0) {\r\n return this.depth;\r\n }\r\n \r\n this.depth = 0;\r\n let currentElement = this.element;\r\n \r\n while (currentElement.nodeType === Node.ELEMENT_NODE) { \r\n this.depth++;\r\n currentElement = getParentNode(currentElement);\r\n }\r\n \r\n return this.depth;\r\n }\r\n }\r\n\r\n function getParentNode(element) {\r\n if (!element || element.nodeType !== Node.ELEMENT_NODE) return null;\r\n \r\n let parent = null;\r\n // SF is using slots and shadow DOM heavily\r\n // However, there might be slots in the light DOM which shouldn't be traversed\r\n if (element.assignedSlot && element.getRootNode() instanceof ShadowRoot)\r\n parent = element.assignedSlot;\r\n else \r\n parent = element.parentNode;\r\n \r\n // Check if we're at a shadow root\r\n if (parent && parent.nodeType !== Node.ELEMENT_NODE && parent.getRootNode() instanceof ShadowRoot) \r\n parent = parent.getRootNode().host; \r\n\r\n return parent;\r\n }\n\n // License: MIT\n // Author: Anton Medvedev <anton@medv.io>\n // Source: https://github.com/antonmedv/finder\n const acceptedAttrNames = new Set(['role', 'name', 'aria-label', 'rel', 'href']);\n /** Check if attribute name and value are word-like. */\n function attr(name, value) {\n let nameIsOk = acceptedAttrNames.has(name);\n nameIsOk ||= name.startsWith('data-') && wordLike(name);\n let valueIsOk = wordLike(value) && value.length < 100;\n valueIsOk ||= value.startsWith('#') && wordLike(value.slice(1));\n return nameIsOk && valueIsOk;\n }\n /** Check if id name is word-like. */\n function idName(name) {\n return wordLike(name);\n }\n /** Check if class name is word-like. */\n function className(name) {\n return wordLike(name);\n }\n /** Check if tag name is word-like. */\n function tagName(name) {\n return true;\n }\n /** Finds unique CSS selectors for the given element. */\n function finder(input, options) {\n if (input.nodeType !== Node.ELEMENT_NODE) {\n throw new Error(`Can't generate CSS selector for non-element node type.`);\n }\n if (input.tagName.toLowerCase() === 'html') {\n return 'html';\n }\n const defaults = {\n root: document.body,\n idName: idName,\n className: className,\n tagName: tagName,\n attr: attr,\n timeoutMs: 1000,\n seedMinLength: 3,\n optimizedMinLength: 2,\n maxNumberOfPathChecks: Infinity,\n };\n const startTime = new Date();\n const config = { ...defaults, ...options };\n const rootDocument = findRootDocument(config.root, defaults);\n let foundPath;\n let count = 0;\n for (const candidate of search(input, config, rootDocument)) {\n const elapsedTimeMs = new Date().getTime() - startTime.getTime();\n if (elapsedTimeMs > config.timeoutMs ||\n count >= config.maxNumberOfPathChecks) {\n const fPath = fallback(input, rootDocument);\n if (!fPath) {\n throw new Error(`Timeout: Can't find a unique selector after ${config.timeoutMs}ms`);\n }\n return selector(fPath);\n }\n count++;\n if (unique(candidate, rootDocument)) {\n foundPath = candidate;\n break;\n }\n }\n if (!foundPath) {\n throw new Error(`Selector was not found.`);\n }\n const optimized = [\n ...optimize(foundPath, input, config, rootDocument, startTime),\n ];\n optimized.sort(byPenalty);\n if (optimized.length > 0) {\n return selector(optimized[0]);\n }\n return selector(foundPath);\n }\n function* search(input, config, rootDocument) {\n const stack = [];\n let paths = [];\n let current = input;\n let i = 0;\n while (current && current !== rootDocument) {\n const level = tie(current, config);\n for (const node of level) {\n node.level = i;\n }\n stack.push(level);\n current = current.parentElement;\n i++;\n paths.push(...combinations(stack));\n if (i >= config.seedMinLength) {\n paths.sort(byPenalty);\n for (const candidate of paths) {\n yield candidate;\n }\n paths = [];\n }\n }\n paths.sort(byPenalty);\n for (const candidate of paths) {\n yield candidate;\n }\n }\n function wordLike(name) {\n if (/^[a-z\\-]{3,}$/i.test(name)) {\n const words = name.split(/-|[A-Z]/);\n for (const word of words) {\n if (word.length <= 2) {\n return false;\n }\n if (/[^aeiou]{4,}/i.test(word)) {\n return false;\n }\n }\n return true;\n }\n return false;\n }\n function tie(element, config) {\n const level = [];\n const elementId = element.getAttribute('id');\n if (elementId && config.idName(elementId)) {\n level.push({\n name: '#' + CSS.escape(elementId),\n penalty: 0,\n });\n }\n for (let i = 0; i < element.classList.length; i++) {\n const name = element.classList[i];\n if (config.className(name)) {\n level.push({\n name: '.' + CSS.escape(name),\n penalty: 1,\n });\n }\n }\n for (let i = 0; i < element.attributes.length; i++) {\n const attr = element.attributes[i];\n if (config.attr(attr.name, attr.value)) {\n level.push({\n name: `[${CSS.escape(attr.name)}=\"${CSS.escape(attr.value)}\"]`,\n penalty: 2,\n });\n }\n }\n const tagName = element.tagName.toLowerCase();\n if (config.tagName(tagName)) {\n level.push({\n name: tagName,\n penalty: 5,\n });\n const index = indexOf(element, tagName);\n if (index !== undefined) {\n level.push({\n name: nthOfType(tagName, index),\n penalty: 10,\n });\n }\n }\n const nth = indexOf(element);\n if (nth !== undefined) {\n level.push({\n name: nthChild(tagName, nth),\n penalty: 50,\n });\n }\n return level;\n }\n function selector(path) {\n let node = path[0];\n let query = node.name;\n for (let i = 1; i < path.length; i++) {\n const level = path[i].level || 0;\n if (node.level === level - 1) {\n query = `${path[i].name} > ${query}`;\n }\n else {\n query = `${path[i].name} ${query}`;\n }\n node = path[i];\n }\n return query;\n }\n function penalty(path) {\n return path.map((node) => node.penalty).reduce((acc, i) => acc + i, 0);\n }\n function byPenalty(a, b) {\n return penalty(a) - penalty(b);\n }\n function indexOf(input, tagName) {\n const parent = input.parentNode;\n if (!parent) {\n return undefined;\n }\n let child = parent.firstChild;\n if (!child) {\n return undefined;\n }\n let i = 0;\n while (child) {\n if (child.nodeType === Node.ELEMENT_NODE &&\n (tagName === undefined ||\n child.tagName.toLowerCase() === tagName)) {\n i++;\n }\n if (child === input) {\n break;\n }\n child = child.nextSibling;\n }\n return i;\n }\n function fallback(input, rootDocument) {\n let i = 0;\n let current = input;\n const path = [];\n while (current && current !== rootDocument) {\n const tagName = current.tagName.toLowerCase();\n const index = indexOf(current, tagName);\n if (index === undefined) {\n return;\n }\n path.push({\n name: nthOfType(tagName, index),\n penalty: NaN,\n level: i,\n });\n current = current.parentElement;\n i++;\n }\n if (unique(path, rootDocument)) {\n return path;\n }\n }\n function nthChild(tagName, index) {\n if (tagName === 'html') {\n return 'html';\n }\n return `${tagName}:nth-child(${index})`;\n }\n function nthOfType(tagName, index) {\n if (tagName === 'html') {\n return 'html';\n }\n return `${tagName}:nth-of-type(${index})`;\n }\n function* combinations(stack, path = []) {\n if (stack.length > 0) {\n for (let node of stack[0]) {\n yield* combinations(stack.slice(1, stack.length), path.concat(node));\n }\n }\n else {\n yield path;\n }\n }\n function findRootDocument(rootNode, defaults) {\n if (rootNode.nodeType === Node.DOCUMENT_NODE) {\n return rootNode;\n }\n if (rootNode === defaults.root) {\n return rootNode.ownerDocument;\n }\n return rootNode;\n }\n function unique(path, rootDocument) {\n const css = selector(path);\n switch (rootDocument.querySelectorAll(css).length) {\n case 0:\n throw new Error(`Can't select any node with this selector: ${css}`);\n case 1:\n return true;\n default:\n return false;\n }\n }\n function* optimize(path, input, config, rootDocument, startTime) {\n if (path.length > 2 && path.length > config.optimizedMinLength) {\n for (let i = 1; i < path.length - 1; i++) {\n const elapsedTimeMs = new Date().getTime() - startTime.getTime();\n if (elapsedTimeMs > config.timeoutMs) {\n return;\n }\n const newPath = [...path];\n newPath.splice(i, 1);\n if (unique(newPath, rootDocument) &&\n rootDocument.querySelector(selector(newPath)) === input) {\n yield newPath;\n yield* optimize(newPath, input, config, rootDocument, startTime);\n }\n }\n }\n }\n\n // import { realpath } from \"fs\";\r\n\r\n function getAllDocumentElementsIncludingShadow(selectors, root = document) {\r\n const elements = Array.from(root.querySelectorAll(selectors));\r\n\r\n root.querySelectorAll('*').forEach(el => {\r\n if (el.shadowRoot) {\r\n elements.push(...getAllDocumentElementsIncludingShadow(selectors, el.shadowRoot));\r\n }\r\n });\r\n return elements;\r\n }\r\n\r\n function getAllFrames(root = document) {\r\n const result = [root];\r\n const frames = getAllDocumentElementsIncludingShadow('frame, iframe', root); \r\n frames.forEach(frame => {\r\n try {\r\n const frameDocument = frame.contentDocument || frame.contentWindow.document;\r\n if (frameDocument) {\r\n result.push(frameDocument);\r\n }\r\n } catch (e) {\r\n // Skip cross-origin frames\r\n console.warn('Could not access frame content:', e.message);\r\n }\r\n });\r\n\r\n return result;\r\n }\r\n\r\n function getAllElementsIncludingShadow(selectors, root = document) {\r\n const elements = [];\r\n\r\n getAllFrames(root).forEach(doc => {\r\n elements.push(...getAllDocumentElementsIncludingShadow(selectors, doc));\r\n });\r\n\r\n return elements;\r\n }\r\n\r\n /**\r\n * Deeply searches through DOM trees including Shadow DOM and frames/iframes\r\n * @param {string} selector - CSS selector to search for\r\n * @param {Document|Element} [root=document] - Starting point for the search\r\n * @param {Object} [options] - Search options\r\n * @param {boolean} [options.searchShadow=true] - Whether to search Shadow DOM\r\n * @param {boolean} [options.searchFrames=true] - Whether to search frames/iframes\r\n * @returns {Element[]} Array of found elements\r\n \r\n function getAllElementsIncludingShadow(selector, root = document, options = {}) {\r\n const {\r\n searchShadow = true,\r\n searchFrames = true\r\n } = options;\r\n\r\n const results = new Set();\r\n \r\n // Helper to check if an element is valid and not yet found\r\n const addIfValid = (element) => {\r\n if (element && !results.has(element)) {\r\n results.add(element);\r\n }\r\n };\r\n\r\n // Helper to process a single document or element\r\n function processNode(node) {\r\n // Search regular DOM\r\n node.querySelectorAll(selector).forEach(addIfValid);\r\n\r\n if (searchShadow) {\r\n // Search all shadow roots\r\n const treeWalker = document.createTreeWalker(\r\n node,\r\n NodeFilter.SHOW_ELEMENT,\r\n {\r\n acceptNode: (element) => {\r\n return element.shadowRoot ? \r\n NodeFilter.FILTER_ACCEPT : \r\n NodeFilter.FILTER_SKIP;\r\n }\r\n }\r\n );\r\n\r\n while (treeWalker.nextNode()) {\r\n const element = treeWalker.currentNode;\r\n if (element.shadowRoot) {\r\n // Search within shadow root\r\n element.shadowRoot.querySelectorAll(selector).forEach(addIfValid);\r\n // Recursively process the shadow root for nested shadow DOMs\r\n processNode(element.shadowRoot);\r\n }\r\n }\r\n }\r\n\r\n if (searchFrames) {\r\n // Search frames and iframes\r\n const frames = node.querySelectorAll('frame, iframe');\r\n frames.forEach(frame => {\r\n try {\r\n const frameDocument = frame.contentDocument;\r\n if (frameDocument) {\r\n processNode(frameDocument);\r\n }\r\n } catch (e) {\r\n // Skip cross-origin frames\r\n console.warn('Could not access frame content:', e.message);\r\n }\r\n });\r\n }\r\n }\r\n\r\n // Start processing from the root\r\n processNode(root);\r\n\r\n return Array.from(results);\r\n }\r\n */\r\n // <div x=1 y=2 role='combobox'> </div>\r\n function findDropdowns() {\r\n const dropdowns = [];\r\n \r\n // Native select elements\r\n dropdowns.push(...getAllElementsIncludingShadow('select'));\r\n \r\n // Elements with dropdown roles that don't have <input>..</input>\r\n const roleElements = getAllElementsIncludingShadow('[role=\"combobox\"], [role=\"listbox\"], [role=\"dropdown\"], [role=\"option\"], [role=\"menu\"], [role=\"menuitem\"]').filter(el => {\r\n return el.tagName.toLowerCase() !== 'input' || ![\"button\", \"checkbox\", \"radio\"].includes(el.getAttribute(\"type\"));\r\n });\r\n dropdowns.push(...roleElements);\r\n \r\n // Common dropdown class patterns\r\n const dropdownPattern = /.*(dropdown|select|combobox|menu).*/i;\r\n const elements = getAllElementsIncludingShadow('*');\r\n const dropdownClasses = Array.from(elements).filter(el => {\r\n const hasDropdownClass = dropdownPattern.test(el.className);\r\n const validTag = ['li', 'ul', 'span', 'div', 'p', 'a', 'button'].includes(el.tagName.toLowerCase());\r\n const style = window.getComputedStyle(el); \r\n const result = hasDropdownClass && validTag && (style.cursor === 'pointer' || el.tagName.toLowerCase() === 'a' || el.tagName.toLowerCase() === 'button');\r\n return result;\r\n });\r\n \r\n dropdowns.push(...dropdownClasses);\r\n \r\n // Elements with aria-haspopup attribute\r\n dropdowns.push(...getAllElementsIncludingShadow('[aria-haspopup=\"true\"], [aria-haspopup=\"listbox\"], [aria-haspopup=\"menu\"]'));\r\n\r\n // Improve navigation element detection\r\n // Semantic nav elements with list items\r\n dropdowns.push(...getAllElementsIncludingShadow('nav ul li, nav ol li'));\r\n \r\n // Navigation elements in common design patterns\r\n dropdowns.push(...getAllElementsIncludingShadow('header a, .header a, .nav a, .navigation a, .menu a, .sidebar a, aside a'));\r\n \r\n // Elements in primary navigation areas with common attributes\r\n dropdowns.push(...getAllElementsIncludingShadow('[role=\"navigation\"] a, [aria-label*=\"navigation\"] a, [aria-label*=\"menu\"] a'));\r\n\r\n return dropdowns;\r\n }\r\n\r\n function findClickables() {\r\n const clickables = [];\r\n \r\n const checkboxPattern = /checkbox/i;\r\n // Collect all clickable elements first\r\n const nativeLinks = [...getAllElementsIncludingShadow('a')];\r\n const nativeButtons = [...getAllElementsIncludingShadow('button')];\r\n const inputButtons = [...getAllElementsIncludingShadow('input[type=\"button\"], input[type=\"submit\"], input[type=\"reset\"]')];\r\n const roleButtons = [...getAllElementsIncludingShadow('[role=\"button\"]')];\r\n // const tabbable = [...getAllElementsIncludingShadow('[tabindex=\"0\"]')];\r\n const clickHandlers = [...getAllElementsIncludingShadow('[onclick]')];\r\n const dropdowns = findDropdowns();\r\n const nativeCheckboxes = [...getAllElementsIncludingShadow('input[type=\"checkbox\"]')]; \r\n const fauxCheckboxes = getAllElementsIncludingShadow('*').filter(el => {\r\n if (checkboxPattern.test(el.className)) {\r\n const realCheckboxes = getAllElementsIncludingShadow('input[type=\"checkbox\"]', el);\r\n if (realCheckboxes.length === 1) {\r\n const boundingRect = realCheckboxes[0].getBoundingClientRect();\r\n return boundingRect.width <= 1 && boundingRect.height <= 1 \r\n }\r\n }\r\n return false;\r\n });\r\n const nativeRadios = [...getAllElementsIncludingShadow('input[type=\"radio\"]')];\r\n const toggles = findToggles();\r\n const pointerElements = findElementsWithPointer();\r\n // Add all elements at once\r\n clickables.push(\r\n ...nativeLinks,\r\n ...nativeButtons,\r\n ...inputButtons,\r\n ...roleButtons,\r\n // ...tabbable,\r\n ...clickHandlers,\r\n ...dropdowns,\r\n ...nativeCheckboxes,\r\n ...fauxCheckboxes,\r\n ...nativeRadios,\r\n ...toggles,\r\n ...pointerElements\r\n );\r\n\r\n // Only uniquify once at the end\r\n return clickables; // Let findElements handle the uniquification\r\n }\r\n\r\n function findToggles() {\r\n const toggles = [];\r\n const checkboxes = getAllElementsIncludingShadow('input[type=\"checkbox\"]');\r\n const togglePattern = /switch|toggle|slider/i;\r\n\r\n checkboxes.forEach(checkbox => {\r\n let isToggle = false;\r\n\r\n // Check the checkbox itself\r\n if (togglePattern.test(checkbox.className) || togglePattern.test(checkbox.getAttribute('role') || '')) {\r\n isToggle = true;\r\n }\r\n\r\n // Check parent elements (up to 3 levels)\r\n if (!isToggle) {\r\n let element = checkbox;\r\n for (let i = 0; i < 3; i++) {\r\n const parent = element.parentElement;\r\n if (!parent) break;\r\n\r\n const className = parent.className || '';\r\n const role = parent.getAttribute('role') || '';\r\n\r\n if (togglePattern.test(className) || togglePattern.test(role)) {\r\n isToggle = true;\r\n break;\r\n }\r\n element = parent;\r\n }\r\n }\r\n\r\n // Check next sibling\r\n if (!isToggle) {\r\n const nextSibling = checkbox.nextElementSibling;\r\n if (nextSibling) {\r\n const className = nextSibling.className || '';\r\n const role = nextSibling.getAttribute('role') || '';\r\n if (togglePattern.test(className) || togglePattern.test(role)) {\r\n isToggle = true;\r\n }\r\n }\r\n }\r\n\r\n if (isToggle) {\r\n toggles.push(checkbox);\r\n }\r\n });\r\n\r\n return toggles;\r\n }\r\n\r\n function findNonInteractiveElements() {\r\n // Get all elements in the document\r\n const all = Array.from(getAllElementsIncludingShadow('*'));\r\n \r\n // Filter elements based on Python implementation rules\r\n return all.filter(element => {\r\n if (!element.firstElementChild) {\r\n const tag = element.tagName.toLowerCase(); \r\n if (!['select', 'button', 'a'].includes(tag)) {\r\n const validTags = ['p', 'span', 'div', 'input', 'textarea','td','th'].includes(tag) || /^h\\d$/.test(tag) || /text/.test(tag);\r\n const boundingRect = element.getBoundingClientRect();\r\n return validTags && boundingRect.height > 1 && boundingRect.width > 1;\r\n }\r\n }\r\n return false;\r\n });\r\n }\r\n\r\n\r\n\r\n // export function findNonInteractiveElements() {\r\n // const all = [];\r\n // try {\r\n // const elements = getAllElementsIncludingShadow('*');\r\n // all.push(...elements);\r\n // } catch (e) {\r\n // console.warn('Error getting elements:', e);\r\n // }\r\n \r\n // console.debug('Total elements found:', all.length);\r\n \r\n // return all.filter(element => {\r\n // try {\r\n // const tag = element.tagName.toLowerCase(); \r\n\r\n // // Special handling for input elements\r\n // if (tag === 'input' || tag === 'textarea') {\r\n // const boundingRect = element.getBoundingClientRect();\r\n // const value = element.value || '';\r\n // const placeholder = element.placeholder || '';\r\n // return boundingRect.height > 1 && \r\n // boundingRect.width > 1 && \r\n // (value.trim() !== '' || placeholder.trim() !== '');\r\n // }\r\n\r\n \r\n // // Check if it's a valid tag for text content\r\n // const validTags = ['p', 'span', 'div', 'label', 'th', 'td', 'li', 'button', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'select'].includes(tag) || \r\n // /^h\\d$/.test(tag) || \r\n // /text/.test(tag);\r\n\r\n // const boundingRect = element.getBoundingClientRect();\r\n\r\n // // Get direct text content, excluding child element text\r\n // let directText = '';\r\n // for (const node of element.childNodes) {\r\n // // Only include text nodes (nodeType 3)\r\n // if (node.nodeType === 3) {\r\n // directText += node.textContent || '';\r\n // }\r\n // }\r\n \r\n // // If no direct text and it's a table cell or heading, check label content\r\n // if (!directText.trim() && (tag === 'th' || tag === 'td' || tag === 'h1')) {\r\n // const labels = element.getElementsByTagName('label');\r\n // for (const label of labels) {\r\n // directText += label.textContent || '';\r\n // }\r\n // }\r\n\r\n // // If still no text and it's a heading, get all text content\r\n // if (!directText.trim() && tag === 'h1') {\r\n // directText = element.textContent || '';\r\n // }\r\n\r\n // directText = directText.trim();\r\n\r\n // // Debug logging\r\n // if (directText) {\r\n // console.debugg('Text element found:', {\r\n // tag,\r\n // text: directText,\r\n // dimensions: boundingRect,\r\n // element\r\n // });\r\n // }\r\n\r\n // return validTags && \r\n // boundingRect.height > 1 && \r\n // boundingRect.width > 1 && \r\n // directText !== '';\r\n \r\n // } catch (e) {\r\n // console.warn('Error processing element:', e);\r\n // return false;\r\n // }\r\n // });\r\n // }\r\n\r\n\r\n\r\n\r\n\r\n function findElementsWithPointer() {\r\n const elements = [];\r\n const allElements = getAllElementsIncludingShadow('*');\r\n \r\n console.log('Checking elements with pointer style...');\r\n \r\n allElements.forEach(element => {\r\n // Skip SVG elements for now\r\n if (element instanceof SVGElement || element.tagName.toLowerCase() === 'svg') {\r\n return;\r\n }\r\n \r\n const style = window.getComputedStyle(element);\r\n if (style.cursor === 'pointer') {\r\n elements.push(element);\r\n }\r\n });\r\n \r\n console.log(`Found ${elements.length} elements with pointer cursor`);\r\n return elements;\r\n }\r\n\r\n function findCheckables() {\r\n const elements = [];\r\n\r\n elements.push(...getAllElementsIncludingShadow('input[type=\"checkbox\"]'));\r\n elements.push(...getAllElementsIncludingShadow('input[type=\"radio\"]'));\r\n const all_elements = getAllElementsIncludingShadow('label');\r\n const radioClasses = Array.from(all_elements).filter(el => {\r\n return /.*radio.*/i.test(el.className); \r\n });\r\n elements.push(...radioClasses);\r\n return elements;\r\n }\r\n\r\n function findFillables() {\r\n const elements = [];\r\n\r\n const inputs = [...getAllElementsIncludingShadow('input:not([type=\"radio\"]):not([type=\"checkbox\"])')];\r\n console.log('Found inputs:', inputs.length, inputs);\r\n elements.push(...inputs);\r\n \r\n const textareas = [...getAllElementsIncludingShadow('textarea')];\r\n console.log('Found textareas:', textareas.length);\r\n elements.push(...textareas);\r\n \r\n const editables = [...getAllElementsIncludingShadow('[contenteditable=\"true\"]')];\r\n console.log('Found editables:', editables.length);\r\n elements.push(...editables);\r\n\r\n return elements;\r\n }\n\n // Helper function to check if element is a form control\r\n function isFormControl(elementInfo) {\r\n return /^(input|select|textarea|button|label)$/i.test(elementInfo.tag);\r\n }\r\n\r\n const isDropdownItem = (elementInfo) => {\r\n const dropdownPatterns = [\r\n /dropdown[-_]?item/i, // matches: dropdown-item, dropdownitem, dropdown_item\r\n /menu[-_]?item/i, // matches: menu-item, menuitem, menu_item\r\n /dropdown[-_]?link/i, // matches: dropdown-link, dropdownlink, dropdown_link\r\n /list[-_]?item/i, // matches: list-item, listitem, list_item\r\n /select[-_]?item/i, // matches: select-item, selectitem, select_item \r\n ];\r\n\r\n const rolePatterns = [\r\n /menu[-_]?item/i, // matches: menuitem, menu-item\r\n /option/i, // matches: option\r\n /list[-_]?item/i, // matches: listitem, list-item\r\n /tree[-_]?item/i // matches: treeitem, tree-item\r\n ];\r\n\r\n const hasMatchingClass = elementInfo.element.className && \r\n dropdownPatterns.some(pattern => \r\n pattern.test(elementInfo.element.className)\r\n );\r\n\r\n const hasMatchingRole = elementInfo.element.getAttribute('role') && \r\n rolePatterns.some(pattern => \r\n pattern.test(elementInfo.element.getAttribute('role'))\r\n );\r\n\r\n return hasMatchingClass || hasMatchingRole;\r\n };\r\n\r\n /**\r\n * Finds the first element matching a CSS selector, traversing Shadow DOM if necessary\r\n * @param {string} selector - CSS selector to search for\r\n * @param {Element} [root=document] - Root element to start searching from\r\n * @returns {Element|null} - The first matching element or null if not found\r\n */\r\n function querySelectorShadow(selector, root = document) {\r\n // First try to find in light DOM\r\n let element = root.querySelector(selector);\r\n if (element) return element;\r\n \r\n // Get all elements with shadow root\r\n const shadowElements = Array.from(root.querySelectorAll('*'))\r\n .filter(el => el.shadowRoot);\r\n \r\n // Search through each shadow root until we find a match\r\n for (const el of shadowElements) {\r\n element = querySelectorShadow(selector, el.shadowRoot);\r\n if (element) return element;\r\n }\r\n \r\n return null;\r\n }\r\n\r\n const getElementByXPathOrCssSelector = (element_info) => {\r\n console.log('getElementByXPathOrCssSelector:', element_info);\r\n\r\n findElement(document, element_info.iframe_selector, element_info.css_selector);\r\n };\r\n\r\n const findElement = (root, iframeSelector, cssSelector) => {\r\n let element;\r\n \r\n if (iframeSelector) { \r\n const frames = getAllDocumentElementsIncludingShadow('iframe', root);\r\n \r\n // Iterate over all frames and compare their CSS selectors\r\n for (const frame of frames) {\r\n const selector = generateCssPath(frame);\r\n if (selector === iframeSelector) {\r\n const frameDocument = frame.contentDocument || frame.contentWindow.document;\r\n element = querySelectorShadow(cssSelector, frameDocument);\r\n console.log('found element ', element);\r\n break;\r\n } \r\n } }\r\n else\r\n element = querySelectorShadow(cssSelector, root);\r\n \r\n if (!element) {\r\n console.warn('Failed to find element with CSS selector:', cssSelector);\r\n }\r\n\r\n return element;\r\n };\r\n\r\n\r\n function isDecendent(parent, child) {\r\n let element = child;\r\n while (element && element !== parent && element.nodeType === Node.ELEMENT_NODE) { \r\n element = getParentNode(element); \r\n }\r\n return element === parent;\r\n }\r\n\r\n function generateXPath(element) {\r\n return '/'+extractElementPath(element).map(item => `${item.tagName}${item.onlyChild ? '' : `[${item.index}]`}`).join('/');\r\n }\r\n\r\n function generateCssPath(element) {\r\n return extractElementPath(element).map(item => `${item.tagName}:nth-of-type(${item.index})`).join(' > ');\r\n }\r\n\r\n function extractElementPath(element) {\r\n if (!element) {\r\n console.error('ERROR: No element provided to generatePath');\r\n return [];\r\n }\r\n const path = [];\r\n // traversing up the DOM tree\r\n while (element && element.nodeType === Node.ELEMENT_NODE) { \r\n let tagName = element.nodeName.toLowerCase();\r\n \r\n let sibling = element;\r\n let index = 1;\r\n \r\n while (sibling = sibling.previousElementSibling) {\r\n if (sibling.nodeName.toLowerCase() === tagName) index++;\r\n }\r\n sibling = element;\r\n \r\n let onlyChild = (index === 1);\r\n while (onlyChild && (sibling = sibling.nextElementSibling)) {\r\n if (sibling.nodeName.toLowerCase() === tagName) onlyChild = false;\r\n }\r\n \r\n // add a tuple with tagName, index (nth), and onlyChild \r\n path.unshift({\r\n tagName: tagName,\r\n index: index,\r\n onlyChild: onlyChild \r\n }); \r\n\r\n element = getParentNode(element);\r\n }\r\n \r\n return path;\r\n }\r\n\r\n function cleanHTML(rawHTML) {\r\n const parser = new DOMParser();\r\n const doc = parser.parseFromString(rawHTML, \"text/html\");\r\n\r\n function cleanElement(element) {\r\n const allowedAttributes = new Set([\r\n \"role\",\r\n \"type\",\r\n \"class\",\r\n \"href\",\r\n \"alt\",\r\n \"title\",\r\n \"readonly\",\r\n \"checked\",\r\n \"enabled\",\r\n \"disabled\",\r\n ]);\r\n\r\n [...element.attributes].forEach(attr => {\r\n const name = attr.name.toLowerCase();\r\n const value = attr.value;\r\n\r\n const isTestAttribute = /^(testid|test-id|data-test-id)$/.test(name);\r\n const isDataAttribute = name.startsWith(\"data-\") && value;\r\n const isBooleanAttribute = [\"readonly\", \"checked\", \"enabled\", \"disabled\"].includes(name);\r\n\r\n if (!allowedAttributes.has(name) && !isDataAttribute && !isTestAttribute && !isBooleanAttribute) {\r\n element.removeAttribute(name);\r\n }\r\n });\r\n\r\n // Handle SVG content - more aggressive replacement\r\n if (element.tagName.toLowerCase() === \"svg\") {\r\n // Remove all attributes except class and role\r\n [...element.attributes].forEach(attr => {\r\n const name = attr.name.toLowerCase();\r\n if (name !== \"class\" && name !== \"role\") {\r\n element.removeAttribute(name);\r\n }\r\n });\r\n element.innerHTML = \"CONTENT REMOVED\";\r\n } else {\r\n // Recursively clean child elements\r\n Array.from(element.children).forEach(cleanElement);\r\n }\r\n\r\n // Only remove empty elements that aren't semantic or icon elements\r\n const keepEmptyElements = ['i', 'span', 'svg', 'button', 'input'];\r\n if (!keepEmptyElements.includes(element.tagName.toLowerCase()) && \r\n !element.children.length && \r\n !element.textContent.trim()) {\r\n element.remove();\r\n }\r\n }\r\n\r\n // Process all elements in the document body\r\n Array.from(doc.body.children).forEach(cleanElement);\r\n return doc.body.innerHTML;\r\n }\r\n\r\n function getContainingIframe(element) {\r\n // If not in an iframe, return null\r\n if (element.ownerDocument.defaultView === window.top) {\r\n return null;\r\n }\r\n \r\n // Try to find the iframe in the parent document that contains our element\r\n try {\r\n const parentDocument = element.ownerDocument.defaultView.parent.document;\r\n const iframes = parentDocument.querySelectorAll('iframe');\r\n \r\n for (const iframe of iframes) {\r\n if (iframe.contentWindow === element.ownerDocument.defaultView) {\r\n return iframe;\r\n }\r\n }\r\n } catch (e) {\r\n // Cross-origin restriction\r\n return \"Cross-origin iframe - cannot access details\";\r\n }\r\n \r\n return null;\r\n }\r\n\r\n function getElementInfo(element, index) {\r\n // Get text content with spaces between elements\r\n /* function getTextContent(element) {\r\n const walker = document.createTreeWalker(\r\n element,\r\n NodeFilter.SHOW_TEXT,\r\n null,\r\n false\r\n );\r\n\r\n let text = '';\r\n let node;\r\n\r\n while (node = walker.nextNode()) {\r\n const trimmedText = node.textContent.trim();\r\n if (trimmedText) {\r\n // Add space if there's already text\r\n if (text) {\r\n text += ' ';\r\n }\r\n text += trimmedText;\r\n }\r\n }\r\n\r\n return text;\r\n } */\r\n\r\n const xpath = generateXPath(element);\r\n const css_selector = generateCssPath(element);\r\n //disabled since it's blocking event handling in recorder\r\n const short_css_selector = ''; //getRobustSelector(element);\r\n\r\n const iframe = getContainingIframe(element); \r\n const iframe_selector = iframe ? generateCssPath(iframe) : \"\";\r\n //disabled since it's blocking event handling in recorder\r\n const short_iframe_selector = ''; //iframe ? getRobustSelector(iframe) : \"\";\r\n\r\n // Return element info with pre-calculated values\r\n return new ElementInfo(element, index, {\r\n tag: element.tagName.toLowerCase(),\r\n type: element.type || '',\r\n text: element.innerText || element.placeholder || '', //getTextContent(element),\r\n html: cleanHTML(element.outerHTML),\r\n xpath: xpath,\r\n css_selector: css_selector,\r\n bounding_box: element.getBoundingClientRect(),\r\n iframe_selector: iframe_selector,\r\n short_css_selector: short_css_selector,\r\n short_iframe_selector: short_iframe_selector\r\n });\r\n }\r\n\r\n function getAriaLabelledByText(elementInfo, includeHidden=true) {\r\n if (!elementInfo.element.hasAttribute('aria-labelledby')) return '';\r\n\r\n const ids = elementInfo.element.getAttribute('aria-labelledby').split(/\\s+/);\r\n let labelText = '';\r\n\r\n //locate root (document or iFrame document if element is contained in an iframe)\r\n let root = document;\r\n if (elementInfo.iframe_selector) { \r\n const frames = getAllDocumentElementsIncludingShadow('iframe', document);\r\n \r\n // Iterate over all frames and compare their CSS selectors\r\n for (const frame of frames) {\r\n const selector = generateCssPath(frame);\r\n if (selector === elementInfo.iframe_selector) {\r\n root = frame.contentDocument || frame.contentWindow.document; \r\n break;\r\n }\r\n } \r\n }\r\n\r\n ids.forEach(id => {\r\n const el = querySelectorShadow(`#${CSS.escape(id)}`, root);\r\n if (el) {\r\n if (includeHidden || el.offsetParent !== null || getComputedStyle(el).display !== 'none') {\r\n labelText += el.textContent.trim() + ' ';\r\n }\r\n }\r\n });\r\n\r\n return labelText.trim();\r\n }\r\n\r\n\r\n\r\n const filterZeroDimensions = (elementInfo) => {\r\n const rect = elementInfo.bounding_box;\r\n //single pixel elements are typically faux controls and should be filtered too\r\n const hasSize = rect.width > 1 && rect.height > 1;\r\n const style = window.getComputedStyle(elementInfo.element);\r\n const isVisible = style.display !== 'none' && style.visibility !== 'hidden';\r\n \r\n if (!hasSize || !isVisible) {\r\n \r\n return false;\r\n }\r\n return true;\r\n };\r\n\r\n\r\n\r\n function uniquifyElements(elements) {\r\n const seen = new Set();\r\n\r\n console.log(`Starting uniquification with ${elements.length} elements`);\r\n\r\n // Filter out testing infrastructure elements first\r\n const filteredInfrastructure = elements.filter(element_info => {\r\n // Skip the highlight-overlay element completely - it's part of the testing infrastructure\r\n if (element_info.element.id === 'highlight-overlay' || \r\n (element_info.css_selector && element_info.css_selector.includes('#highlight-overlay'))) {\r\n console.log('Filtered out testing infrastructure element:', element_info.css_selector);\r\n return false;\r\n }\r\n \r\n // Filter out UI framework container/manager elements\r\n const el = element_info.element;\r\n // UI framework container checks - generic detection for any framework\r\n if ((el.getAttribute('data-rendered-by') || \r\n el.getAttribute('data-reactroot') || \r\n el.getAttribute('ng-version') || \r\n el.getAttribute('data-component-id') ||\r\n el.getAttribute('data-root') ||\r\n el.getAttribute('data-framework')) && \r\n (el.className && \r\n typeof el.className === 'string' && \r\n (el.className.includes('Container') || \r\n el.className.includes('container') || \r\n el.className.includes('Manager') || \r\n el.className.includes('manager')))) {\r\n console.log('Filtered out UI framework container element:', element_info.css_selector);\r\n return false;\r\n }\r\n \r\n // Direct filter for framework container elements that shouldn't be interactive\r\n // Consolidating multiple container detection patterns into one efficient check\r\n const isFullViewport = element_info.bounding_box && \r\n element_info.bounding_box.x <= 5 && \r\n element_info.bounding_box.y <= 5 && \r\n element_info.bounding_box.width >= (window.innerWidth * 0.95) && \r\n element_info.bounding_box.height >= (window.innerHeight * 0.95);\r\n \r\n // Empty content check\r\n const isEmpty = !el.innerText || el.innerText.trim() === '';\r\n \r\n // Check if it's a framework container element\r\n if (element_info.element.tagName === 'DIV' && \r\n isFullViewport && \r\n isEmpty && \r\n (\r\n // Pattern matching for root containers\r\n (element_info.xpath && \r\n (element_info.xpath.match(/^\\/html\\[\\d+\\]\\/body\\[\\d+\\]\\/div\\[\\d+\\]\\/div\\[\\d+\\]$/) || \r\n element_info.xpath.match(/^\\/\\/\\*\\[@id='[^']+'\\]\\/div\\[\\d+\\]$/))) ||\r\n \r\n // Simple DOM structure\r\n (element_info.css_selector.split(' > ').length <= 4 && element_info.depth <= 5) ||\r\n \r\n // Empty or container-like classes\r\n (!el.className || el.className === '' || \r\n (typeof el.className === 'string' && \r\n (el.className.includes('overlay') || \r\n el.className.includes('container') || \r\n el.className.includes('wrapper'))))\r\n )) {\r\n console.log('Filtered out framework container element:', element_info.css_selector);\r\n return false;\r\n }\r\n \r\n return true;\r\n });\r\n\r\n // First filter out elements with zero dimensions\r\n const nonZeroElements = filteredInfrastructure.filter(filterZeroDimensions);\r\n // sort by CSS selector depth so parents are processed first\r\n nonZeroElements.sort((a, b) => a.getDepth() - b.getDepth());\r\n console.log(`After dimension filtering: ${nonZeroElements.length} elements remain (${elements.length - nonZeroElements.length} removed)`);\r\n \r\n const filteredByParent = nonZeroElements.filter(element_info => {\r\n\r\n const parent = findClosestParent(seen, element_info);\r\n const keep = parent == null || shouldKeepNestedElement(element_info, parent);\r\n // console.log(\"node \", element_info.index, \": keep=\", keep, \" parent=\", parent);\r\n // if (!keep && !element_info.xpath) {\r\n // console.log(\"Filtered out element \", element_info,\" because it's a nested element of \", parent);\r\n // }\r\n if (keep)\r\n seen.add(element_info.css_selector);\r\n\r\n return keep;\r\n });\r\n\r\n console.log(`After parent/child filtering: ${filteredByParent.length} elements remain (${nonZeroElements.length - filteredByParent.length} removed)`);\r\n\r\n // Final overlap filtering\r\n const filteredResults = filteredByParent.filter(element => {\r\n\r\n // Look for any element that came BEFORE this one in the array\r\n const hasEarlierOverlap = filteredByParent.some(other => {\r\n // Only check elements that came before (lower index)\r\n if (filteredByParent.indexOf(other) >= filteredByParent.indexOf(element)) {\r\n return false;\r\n }\r\n \r\n const isOverlapping = areElementsOverlapping(element, other); \r\n return isOverlapping;\r\n }); \r\n\r\n // Keep element if it has no earlier overlapping elements\r\n return !hasEarlierOverlap;\r\n });\r\n \r\n \r\n \r\n // Check for overlay removal\r\n console.log(`After filtering: ${filteredResults.length} (${filteredByParent.length - filteredResults.length} removed by overlap)`);\r\n \r\n const nonOverlaidElements = filteredResults.filter(element => {\r\n return !isOverlaid(element);\r\n });\r\n\r\n console.log(`Final elements after overlay removal: ${nonOverlaidElements.length} (${filteredResults.length - nonOverlaidElements.length} removed)`);\r\n \r\n return nonOverlaidElements;\r\n\r\n }\r\n\r\n\r\n\r\n const areElementsOverlapping = (element1, element2) => {\r\n if (element1.css_selector === element2.css_selector) {\r\n return true;\r\n }\r\n \r\n const box1 = element1.bounding_box;\r\n const box2 = element2.bounding_box;\r\n \r\n return box1.x === box2.x &&\r\n box1.y === box2.y &&\r\n box1.width === box2.width &&\r\n box1.height === box2.height;\r\n // element1.text === element2.text &&\r\n // element2.tag === 'a';\r\n };\r\n\r\n function findClosestParent(seen, element_info) { \r\n \r\n // Split the xpath into segments\r\n const segments = element_info.css_selector.split(' > ');\r\n \r\n // Try increasingly shorter paths until we find one in the seen set\r\n for (let i = segments.length - 1; i > 0; i--) {\r\n const parentPath = segments.slice(0, i).join(' > ');\r\n if (seen.has(parentPath)) {\r\n return parentPath;\r\n }\r\n }\r\n\r\n return null;\r\n }\r\n\r\n function shouldKeepNestedElement(elementInfo, parentPath) {\r\n let result = false;\r\n const parentSegments = parentPath.split(' > ');\r\n\r\n const isParentLink = /^a(:nth-of-type\\(\\d+\\))?$/.test(parentSegments[parentSegments.length - 1]);\r\n if (isParentLink) {\r\n return false; \r\n }\r\n // If this is a checkbox/radio input\r\n if (elementInfo.tag === 'input' && \r\n (elementInfo.type === 'checkbox' || elementInfo.type === 'radio')) {\r\n \r\n // Check if parent is a label by looking at the parent xpath's last segment\r\n \r\n const isParentLabel = /^label(:nth-of-type\\(\\d+\\))?$/.test(parentSegments[parentSegments.length - 1]);\r\n \r\n // If parent is a label, don't keep the input (we'll keep the label instead)\r\n if (isParentLabel) {\r\n return false;\r\n }\r\n }\r\n \r\n // Keep all other form controls and dropdown items\r\n if (isFormControl(elementInfo) || isDropdownItem(elementInfo)) {\r\n result = true;\r\n }\r\n\r\n if(isTableCell(elementInfo)) {\r\n result = true;\r\n }\r\n \r\n \r\n // console.log(`shouldKeepNestedElement: ${elementInfo.tag} ${elementInfo.text} ${elementInfo.xpath} -> ${parentXPath} -> ${result}`);\r\n return result;\r\n }\r\n\r\n\r\n function isTableCell(elementInfo) {\r\n const element = elementInfo.element;\r\n if(!element || !(element instanceof HTMLElement)) {\r\n return false;\r\n }\r\n const validTags = new Set(['td', 'th']);\r\n const validRoles = new Set(['cell', 'gridcell', 'columnheader', 'rowheader']);\r\n \r\n const tag = element.tagName.toLowerCase();\r\n const role = element.getAttribute('role')?.toLowerCase();\r\n\r\n if (validTags.has(tag) || (role && validRoles.has(role))) {\r\n return true;\r\n }\r\n return false;\r\n \r\n }\r\n\r\n function isOverlaid(elementInfo) {\r\n const element = elementInfo.element;\r\n const boundingRect = elementInfo.bounding_box;\r\n \r\n\r\n \r\n \r\n // Create a diagnostic logging function that only logs when needed\r\n const diagnosticLog = (...args) => {\r\n { // set to true for debugging\r\n console.log('[OVERLAY-DEBUG]', ...args);\r\n }\r\n };\r\n\r\n // Special handling for tooltips\r\n if (elementInfo.element.className && typeof elementInfo.element.className === 'string' && \r\n elementInfo.element.className.includes('tooltip')) {\r\n diagnosticLog('Element is a tooltip, not considering it overlaid');\r\n return false;\r\n }\r\n \r\n \r\n \r\n // Get element at the center point to check if it's covered by a popup/modal\r\n const middleX = boundingRect.x + boundingRect.width/2;\r\n const middleY = boundingRect.y + boundingRect.height/2;\r\n const elementAtMiddle = element.ownerDocument.elementFromPoint(middleX, middleY);\r\n \r\n if (elementAtMiddle && \r\n elementAtMiddle !== element && \r\n !isDecendent(element, elementAtMiddle) && \r\n !isDecendent(elementAtMiddle, element)) {\r\n\r\n \r\n return true;\r\n }\r\n \r\n \r\n return false;\r\n \r\n }\r\n\r\n\r\n\r\n /**\r\n * Get the “best” short, unique, and robust CSS selector for an element.\r\n * \r\n * @param {Element} element\r\n * @returns {string} A selector guaranteed to find exactly that element in its context\r\n */\r\n function getRobustSelector(element) {\r\n // 1. Figure out the real “root” (iframe doc, shadow root, or main doc)\r\n const root = (() => {\r\n const rootNode = element.getRootNode();\r\n if (rootNode instanceof ShadowRoot) {\r\n return rootNode;\r\n }\r\n return element.ownerDocument;\r\n })();\r\n\r\n // 2. Options to bias toward stable attrs and away from auto-generated classes\r\n const options = {\r\n root,\r\n // only use data-*, id or aria-label by default\r\n attr(name, value) {\r\n if (name === 'id' || name.startsWith('data-') || name === 'aria-label') {\r\n return true;\r\n }\r\n return false;\r\n },\r\n // skip framework junk\r\n filter(name, value) {\r\n if (name.startsWith('ng-') || name.startsWith('_ngcontent') || /^p-/.test(name)) {\r\n return false;\r\n }\r\n return true;\r\n },\r\n // let finder try really short seeds\r\n seedMinLength: 1,\r\n optimizedMinLength: 1,\r\n };\r\n\r\n let selector;\r\n try {\r\n selector = finder(element, options);\r\n // 3. Verify it really works in the context\r\n const found = root.querySelectorAll(selector);\r\n if (found.length !== 1 || found[0] !== element) {\r\n throw new Error('not unique or not found');\r\n }\r\n return selector;\r\n } catch (err) {\r\n // 4. Fallback: full path (you already have this utility)\r\n console.warn('[getRobustSelector] finder failed, falling back to full path:', err);\r\n return generateCssPath(element); // you’d import or define this elsewhere\r\n }\r\n }\r\n\r\n /**\r\n * Checks if an element is scrollable (has scrollable content)\r\n * \r\n * @param element - The element to check\r\n * @returns boolean indicating if the element is scrollable\r\n */\r\n function isScrollableContainer(element) {\r\n if (!element) return false;\r\n \r\n const style = window.getComputedStyle(element);\r\n \r\n // Reliable way to detect if an element has scrollbars or is scrollable\r\n const hasScrollHeight = element.scrollHeight > element.clientHeight;\r\n const hasScrollWidth = element.scrollWidth > element.clientWidth;\r\n \r\n // Check actual style properties\r\n const hasOverflowY = style.overflowY === 'auto' || \r\n style.overflowY === 'scroll' || \r\n style.overflowY === 'overlay';\r\n const hasOverflowX = style.overflowX === 'auto' || \r\n style.overflowX === 'scroll' || \r\n style.overflowX === 'overlay';\r\n \r\n // Check common class names and attributes for scrollable containers across frameworks\r\n const hasScrollClasses = element.classList.contains('scroll') || \r\n element.classList.contains('scrollable') ||\r\n element.classList.contains('overflow') ||\r\n element.classList.contains('overflow-auto') ||\r\n element.classList.contains('overflow-scroll') ||\r\n element.getAttribute('data-scrollable') === 'true';\r\n \r\n // Check for height/max-height constraints that often indicate scrolling content\r\n const hasHeightConstraint = style.maxHeight && \r\n style.maxHeight !== 'none' && \r\n style.maxHeight !== 'auto';\r\n \r\n // An element is scrollable if it has:\r\n // 1. Actual scrollbars in use (most reliable check) OR\r\n // 2. Overflow styles allowing scrolling AND content that would require scrolling\r\n return (hasScrollHeight && hasOverflowY) || \r\n (hasScrollWidth && hasOverflowX) ||\r\n (hasScrollClasses && (hasScrollHeight || hasScrollWidth)) ||\r\n (hasHeightConstraint && hasScrollHeight);\r\n }\r\n\r\n /**\r\n * Detects scrollable containers that are ancestors of the target element\r\n * \r\n * This function traverses up the DOM tree from the target element and identifies\r\n * all scrollable containers (elements that have scrollable content).\r\n * \r\n * @param target - The target element to start the search from\r\n * @returns Array of objects with selector and scroll properties\r\n */\r\n function detectScrollableContainers(target) {\r\n const scrollableContainers = [];\r\n \r\n if (!target) {\r\n return scrollableContainers;\r\n }\r\n \r\n console.log('🔍 [detectScrollableContainers] Starting detection for target:', target.tagName, target.id, target.className);\r\n \r\n // Detect if target is inside an iframe\r\n const iframe = getContainingIframe(target);\r\n const iframe_selector = iframe ? generateCssPath(iframe) : \"\";\r\n \r\n console.log('🔍 [detectScrollableContainers] Iframe context:', iframe ? 'inside iframe' : 'main document', 'selector:', iframe_selector);\r\n \r\n // Start from the target element and traverse up the DOM tree\r\n let currentElement = target;\r\n let depth = 0;\r\n const MAX_DEPTH = 10; // Limit traversal depth to avoid infinite loops\r\n \r\n while (currentElement && currentElement.nodeType === Node.ELEMENT_NODE && depth < MAX_DEPTH) { \r\n // Check if the current element is scrollable\r\n if (isScrollableContainer(currentElement)) {\r\n console.log('🔍 [detectScrollableContainers] Found scrollable container at depth', depth, ':', currentElement.tagName, currentElement.id, currentElement.className);\r\n \r\n const container = {\r\n containerEl: currentElement,\r\n selector: generateCssPath(currentElement),\r\n iframe_selector: iframe_selector,\r\n scrollTop: currentElement.scrollTop,\r\n scrollLeft: currentElement.scrollLeft,\r\n scrollHeight: currentElement.scrollHeight,\r\n scrollWidth: currentElement.scrollWidth,\r\n clientHeight: currentElement.clientHeight,\r\n clientWidth: currentElement.clientWidth\r\n };\r\n \r\n scrollableContainers.push(container);\r\n }\r\n \r\n // Move to parent element\r\n currentElement = getParentNode(currentElement);\r\n \r\n depth++;\r\n }\r\n \r\n console.log('🔍 [detectScrollableContainers] Detection complete. Found', scrollableContainers.length, 'scrollable containers');\r\n return scrollableContainers;\r\n }\n\n class DOMSerializer {\r\n constructor(options = {}) {\r\n this.options = {\r\n includeStyles: true,\r\n includeScripts: false, // Security consideration\r\n includeFrames: true,\r\n includeShadowDOM: true,\r\n maxDepth: 50,\r\n ...options\r\n };\r\n this.serializedFrames = new Map();\r\n this.shadowRoots = new Map();\r\n }\r\n \r\n /**\r\n * Serialize a complete document or element\r\n */\r\n serialize(rootElement = document) {\r\n try {\r\n const serialized = {\r\n type: 'document',\r\n doctype: this.serializeDoctype(rootElement),\r\n documentElement: this.serializeElement(rootElement.documentElement || rootElement),\r\n frames: [],\r\n timestamp: Date.now(),\r\n url: rootElement.URL || window.location?.href,\r\n metadata: {\r\n title: rootElement.title,\r\n charset: rootElement.characterSet,\r\n contentType: rootElement.contentType\r\n }\r\n };\r\n \r\n // Serialize frames and iframes if enabled\r\n if (this.options.includeFrames) {\r\n serialized.frames = this.serializeFrames(rootElement);\r\n }\r\n \r\n return serialized;\r\n } catch (error) {\r\n console.error('Serialization error:', error);\r\n throw new Error(`DOM serialization failed: ${error.message}`);\r\n }\r\n }\r\n \r\n /**\r\n * Serialize document type declaration\r\n */\r\n serializeDoctype(doc) {\r\n if (!doc.doctype) return null;\r\n \r\n return {\r\n name: doc.doctype.name,\r\n publicId: doc.doctype.publicId,\r\n systemId: doc.doctype.systemId\r\n };\r\n }\r\n \r\n /**\r\n * Serialize an individual element and its children\r\n */\r\n serializeElement(element, depth = 0) {\r\n if (depth > this.options.maxDepth) {\r\n return { type: 'text', content: '<!-- Max depth exceeded -->' };\r\n }\r\n \r\n const nodeType = element.nodeType;\r\n \r\n switch (nodeType) {\r\n case Node.ELEMENT_NODE:\r\n return this.serializeElementNode(element, depth);\r\n case Node.TEXT_NODE:\r\n return this.serializeTextNode(element);\r\n case Node.COMMENT_NODE:\r\n return this.serializeCommentNode(element);\r\n case Node.DOCUMENT_FRAGMENT_NODE:\r\n return this.serializeDocumentFragment(element, depth);\r\n default:\r\n return null;\r\n }\r\n }\r\n \r\n /**\r\n * Serialize element node with attributes and children\r\n */\r\n serializeElementNode(element, depth) {\r\n const tagName = element.tagName.toLowerCase();\r\n \r\n // Skip script tags for security unless explicitly enabled\r\n if (tagName === 'script' && !this.options.includeScripts) {\r\n return { type: 'comment', content: '<!-- Script tag removed for security -->' };\r\n }\r\n \r\n const serialized = {\r\n type: 'element',\r\n tagName: tagName,\r\n attributes: this.serializeAttributes(element),\r\n children: [],\r\n shadowRoot: null\r\n };\r\n \r\n // Handle Shadow DOM\r\n if (this.options.includeShadowDOM && element.shadowRoot) {\r\n serialized.shadowRoot = this.serializeShadowRoot(element.shadowRoot, depth + 1);\r\n }\r\n \r\n // Handle special elements\r\n if (tagName === 'iframe' || tagName === 'frame') {\r\n serialized.frameData = this.serializeFrameElement(element);\r\n }\r\n \r\n // Serialize children\r\n for (const child of element.childNodes) {\r\n const serializedChild = this.serializeElement(child, depth + 1);\r\n if (serializedChild) {\r\n serialized.children.push(serializedChild);\r\n }\r\n }\r\n \r\n // Include computed styles if enabled\r\n if (this.options.includeStyles && element.nodeType === Node.ELEMENT_NODE) {\r\n serialized.computedStyle = this.serializeComputedStyle(element);\r\n }\r\n \r\n return serialized;\r\n }\r\n \r\n /**\r\n * Serialize element attributes\r\n */\r\n serializeAttributes(element) {\r\n const attributes = {};\r\n \r\n if (element.attributes) {\r\n for (const attr of element.attributes) {\r\n attributes[attr.name] = attr.value;\r\n }\r\n }\r\n \r\n return attributes;\r\n }\r\n \r\n /**\r\n * Serialize computed styles\r\n */\r\n serializeComputedStyle(element) {\r\n try {\r\n const computedStyle = window.getComputedStyle(element);\r\n const styles = {};\r\n \r\n // Only serialize non-default values to reduce size\r\n const importantStyles = [\r\n 'display', 'position', 'width', 'height', 'margin', 'padding',\r\n 'border', 'background', 'color', 'font-family', 'font-size',\r\n 'text-align', 'visibility', 'z-index', 'transform'\r\n ];\r\n \r\n for (const prop of importantStyles) {\r\n const value = computedStyle.getPropertyValue(prop);\r\n if (value && value !== 'initial' && value !== 'normal') {\r\n styles[prop] = value;\r\n }\r\n }\r\n \r\n return styles;\r\n } catch (error) {\r\n return {};\r\n }\r\n }\r\n \r\n /**\r\n * Serialize text node\r\n */\r\n serializeTextNode(node) {\r\n return {\r\n type: 'text',\r\n content: node.textContent\r\n };\r\n }\r\n \r\n /**\r\n * Serialize comment node\r\n */\r\n serializeCommentNode(node) {\r\n return {\r\n type: 'comment',\r\n content: node.textContent\r\n };\r\n }\r\n \r\n /**\r\n * Serialize document fragment\r\n */\r\n serializeDocumentFragment(fragment, depth) {\r\n const serialized = {\r\n type: 'fragment',\r\n children: []\r\n };\r\n \r\n for (const child of fragment.childNodes) {\r\n const serializedChild = this.serializeElement(child, depth + 1);\r\n if (serializedChild) {\r\n serialized.children.push(serializedChild);\r\n }\r\n }\r\n \r\n return serialized;\r\n }\r\n \r\n /**\r\n * Serialize Shadow DOM\r\n */\r\n serializeShadowRoot(shadowRoot, depth) {\r\n const serialized = {\r\n type: 'shadowRoot',\r\n mode: shadowRoot.mode,\r\n children: []\r\n };\r\n \r\n for (const child of shadowRoot.childNodes) {\r\n const serializedChild = this.serializeElement(child, depth + 1);\r\n if (serializedChild) {\r\n serialized.children.push(serializedChild);\r\n }\r\n }\r\n \r\n return serialized;\r\n }\r\n \r\n /**\r\n * Serialize frame/iframe elements\r\n */\r\n serializeFrameElement(frameElement) {\r\n const frameData = {\r\n src: frameElement.src,\r\n name: frameElement.name,\r\n id: frameElement.id,\r\n sandbox: frameElement.sandbox?.toString() || '',\r\n allowfullscreen: frameElement.allowFullscreen\r\n };\r\n \r\n // Try to access frame content (may fail due to CORS)\r\n try {\r\n const frameDoc = frameElement.contentDocument;\r\n if (frameDoc && this.options.includeFrames) {\r\n frameData.content = this.serialize(frameDoc);\r\n }\r\n } catch (error) {\r\n frameData.accessError = 'Cross-origin frame content not accessible';\r\n }\r\n \r\n return frameData;\r\n }\r\n \r\n /**\r\n * Serialize all frames in document\r\n */\r\n serializeFrames(doc) {\r\n const frames = [];\r\n const frameElements = doc.querySelectorAll('iframe, frame');\r\n \r\n for (const frameElement of frameElements) {\r\n try {\r\n const frameDoc = frameElement.contentDocument;\r\n if (frameDoc) {\r\n frames.push({\r\n element: this.serializeElement(frameElement),\r\n content: this.serialize(frameDoc)\r\n });\r\n }\r\n } catch (error) {\r\n frames.push({\r\n element: this.serializeElement(frameElement),\r\n error: 'Frame content not accessible'\r\n });\r\n }\r\n }\r\n \r\n return frames;\r\n }\r\n \r\n /**\r\n * Deserialize serialized DOM data back to DOM nodes\r\n */\r\n deserialize(serializedData, targetDocument = document) {\r\n try {\r\n if (serializedData.type === 'document') {\r\n return this.deserializeDocument(serializedData, targetDocument);\r\n } else {\r\n return this.deserializeElement(serializedData, targetDocument);\r\n }\r\n } catch (error) {\r\n console.error('Deserialization error:', error);\r\n throw new Error(`DOM deserialization failed: ${error.message}`);\r\n }\r\n }\r\n \r\n /**\r\n * Deserialize complete document\r\n */\r\n deserializeDocument(serializedDoc, targetDoc) {\r\n // Create new document if needed\r\n const doc = targetDoc || document.implementation.createHTMLDocument();\r\n \r\n // Set doctype if present\r\n if (serializedDoc.doctype) {\r\n const doctype = document.implementation.createDocumentType(\r\n serializedDoc.doctype.name,\r\n serializedDoc.doctype.publicId,\r\n serializedDoc.doctype.systemId\r\n );\r\n doc.replaceChild(doctype, doc.doctype);\r\n }\r\n \r\n // Deserialize document element\r\n if (serializedDoc.documentElement) {\r\n const newDocElement = this.deserializeElement(serializedDoc.documentElement, doc);\r\n doc.replaceChild(newDocElement, doc.documentElement);\r\n }\r\n \r\n // Handle metadata\r\n if (serializedDoc.metadata) {\r\n doc.title = serializedDoc.metadata.title || '';\r\n }\r\n \r\n return doc;\r\n }\r\n \r\n /**\r\n * Deserialize individual element\r\n */\r\n deserializeElement(serializedNode, doc) {\r\n switch (serializedNode.type) {\r\n case 'element':\r\n return this.deserializeElementNode(serializedNode, doc);\r\n case 'text':\r\n return doc.createTextNode(serializedNode.content);\r\n case 'comment':\r\n return doc.createComment(serializedNode.content);\r\n case 'fragment':\r\n return this.deserializeDocumentFragment(serializedNode, doc);\r\n case 'shadowRoot':\r\n // Shadow roots are handled during element creation\r\n return null;\r\n default:\r\n return null;\r\n }\r\n }\r\n \r\n /**\r\n * Deserialize element node\r\n */\r\n deserializeElementNode(serializedElement, doc) {\r\n const element = doc.createElement(serializedElement.tagName);\r\n \r\n // Set attributes\r\n if (serializedElement.attributes) {\r\n for (const [name, value] of Object.entries(serializedElement.attributes)) {\r\n try {\r\n element.setAttribute(name, value);\r\n } catch (error) {\r\n console.warn(`Failed to set attribute ${name}:`, error);\r\n }\r\n }\r\n }\r\n \r\n // Apply computed styles if available\r\n if (serializedElement.computedStyle && this.options.includeStyles) {\r\n for (const [prop, value] of Object.entries(serializedElement.computedStyle)) {\r\n try {\r\n element.style.setProperty(prop, value);\r\n } catch (error) {\r\n console.warn(`Failed to set style ${prop}:`, error);\r\n }\r\n }\r\n }\r\n \r\n // Create shadow root if present\r\n if (serializedElement.shadowRoot && element.attachShadow) {\r\n try {\r\n const shadowRoot = element.attachShadow({ \r\n mode: serializedElement.shadowRoot.mode || 'open' \r\n });\r\n \r\n // Deserialize shadow root children\r\n for (const child of serializedElement.shadowRoot.children) {\r\n const childElement = this.deserializeElement(child, doc);\r\n if (childElement) {\r\n shadowRoot.appendChild(childElement);\r\n }\r\n }\r\n } catch (error) {\r\n console.warn('Failed to create shadow root:', error);\r\n }\r\n }\r\n \r\n // Deserialize children\r\n if (serializedElement.children) {\r\n for (const child of serializedElement.children) {\r\n const childElement = this.deserializeElement(child, doc);\r\n if (childElement) {\r\n element.appendChild(childElement);\r\n }\r\n }\r\n }\r\n \r\n // Handle frame content\r\n if (serializedElement.frameData && serializedElement.frameData.content) {\r\n // Frame content deserialization would happen after the frame loads\r\n element.addEventListener('load', () => {\r\n try {\r\n const frameDoc = element.contentDocument;\r\n if (frameDoc) {\r\n this.deserializeDocument(serializedElement.frameData.content, frameDoc);\r\n }\r\n } catch (error) {\r\n console.warn('Failed to deserialize frame content:', error);\r\n }\r\n });\r\n }\r\n \r\n return element;\r\n }\r\n \r\n /**\r\n * Deserialize document fragment\r\n */\r\n deserializeDocumentFragment(serializedFragment, doc) {\r\n const fragment = doc.createDocumentFragment();\r\n \r\n if (serializedFragment.children) {\r\n for (const child of serializedFragment.children) {\r\n const childElement = this.deserializeElement(child, doc);\r\n if (childElement) {\r\n fragment.appendChild(childElement);\r\n }\r\n }\r\n }\r\n \r\n return fragment;\r\n }\r\n }\r\n \r\n // Usage example and utility functions\r\n class DOMUtils {\r\n /**\r\n * Create serializer with common presets\r\n */\r\n static createSerializer(preset = 'default') {\r\n const presets = {\r\n default: {\r\n includeStyles: true,\r\n includeScripts: false,\r\n includeFrames: true,\r\n includeShadowDOM: true\r\n },\r\n minimal: {\r\n includeStyles: false,\r\n includeScripts: false,\r\n includeFrames: false,\r\n includeShadowDOM: false\r\n },\r\n complete: {\r\n includeStyles: true,\r\n includeScripts: true,\r\n includeFrames: true,\r\n includeShadowDOM: true\r\n },\r\n secure: {\r\n includeStyles: true,\r\n includeScripts: false,\r\n includeFrames: false,\r\n includeShadowDOM: true\r\n }\r\n };\r\n \r\n return new DOMSerializer(presets[preset] || presets.default);\r\n }\r\n \r\n /**\r\n * Serialize DOM to JSON string\r\n */\r\n static serializeToJSON(element, options) {\r\n const serializer = new DOMSerializer(options);\r\n const serialized = serializer.serialize(element);\r\n return JSON.stringify(serialized, null, 2);\r\n }\r\n \r\n /**\r\n * Deserialize from JSON string\r\n */\r\n static deserializeFromJSON(jsonString, targetDocument) {\r\n const serialized = JSON.parse(jsonString);\r\n const serializer = new DOMSerializer();\r\n return serializer.deserialize(serialized, targetDocument);\r\n }\r\n \r\n /**\r\n * Clone DOM with full fidelity including Shadow DOM\r\n */\r\n static deepClone(element, options) {\r\n const serializer = new DOMSerializer(options);\r\n const serialized = serializer.serialize(element);\r\n return serializer.deserialize(serialized, element.ownerDocument);\r\n }\r\n \r\n /**\r\n * Compare two DOM structures\r\n */\r\n static compare(element1, element2, options) {\r\n const serializer = new DOMSerializer(options);\r\n const serialized1 = serializer.serialize(element1);\r\n const serialized2 = serializer.serialize(element2);\r\n \r\n return JSON.stringify(serialized1) === JSON.stringify(serialized2);\r\n }\r\n }\r\n \r\n /*\r\n // Export for use\r\n if (typeof module !== 'undefined' && module.exports) {\r\n module.exports = { DOMSerializer, DOMUtils };\r\n } else if (typeof window !== 'undefined') {\r\n window.DOMSerializer = DOMSerializer;\r\n window.DOMUtils = DOMUtils;\r\n }\r\n */\r\n\r\n /* Usage Examples:\r\n \r\n // Basic serialization\r\n const serializer = new DOMSerializer();\r\n const serialized = serializer.serialize(document);\r\n console.log(JSON.stringify(serialized, null, 2));\r\n \r\n // Deserialize back to DOM\r\n const clonedDoc = serializer.deserialize(serialized);\r\n \r\n // Using presets\r\n const minimalSerializer = DOMUtils.createSerializer('minimal');\r\n const secureSerializer = DOMUtils.createSerializer('secure');\r\n \r\n // Serialize specific element with Shadow DOM\r\n const customElement = document.querySelector('my-custom-element');\r\n const serializedElement = serializer.serialize(customElement);\r\n \r\n // JSON utilities\r\n const jsonString = DOMUtils.serializeToJSON(document.body);\r\n const restored = DOMUtils.deserializeFromJSON(jsonString);\r\n \r\n // Deep clone with Shadow DOM support\r\n const clone = DOMUtils.deepClone(document.body, { includeShadowDOM: true });\r\n \r\n */\r\n\r\n function serializeNodeToJSON(nodeElement) {\r\n return DOMUtils.serializeToJSON(nodeElement, {includeStyles: false});\r\n }\r\n\r\n function deserializeNodeFromJSON(jsonString) {\r\n return DOMUtils.deserializeFromJSON(jsonString);\r\n }\n\n /**\r\n * Checks if a point is inside a bounding box\r\n * \r\n * @param point The point to check\r\n * @param box The bounding box\r\n * @returns boolean indicating if the point is inside the box\r\n */\r\n function isPointInsideBox(point, box) {\r\n return point.x >= box.x &&\r\n point.x <= box.x + box.width &&\r\n point.y >= box.y &&\r\n point.y <= box.y + box.height;\r\n }\r\n\r\n /**\r\n * Calculates the overlap area between two bounding boxes\r\n * \r\n * @param box1 First bounding box\r\n * @param box2 Second bounding box\r\n * @returns The overlap area\r\n */\r\n function calculateOverlap(box1, box2) {\r\n const xOverlap = Math.max(0,\r\n Math.min(box1.x + box1.width, box2.x + box2.width) -\r\n Math.max(box1.x, box2.x)\r\n );\r\n const yOverlap = Math.max(0,\r\n Math.min(box1.y + box1.height, box2.y + box2.height) -\r\n Math.max(box1.y, box2.y)\r\n );\r\n return xOverlap * yOverlap;\r\n }\r\n\r\n /**\r\n * Finds an exact match between candidate elements and the actual interaction element\r\n * \r\n * @param candidate_elements Array of candidate element infos\r\n * @param actualInteractionElementInfo The actual interaction element info\r\n * @returns The matching candidate element info, or null if no match is found\r\n */\r\n function findExactMatch(candidate_elements, actualInteractionElementInfo) {\r\n if (!actualInteractionElementInfo.element) {\r\n return null;\r\n }\r\n\r\n const exactMatch = candidate_elements.find(elementInfo => \r\n elementInfo.element && elementInfo.element === actualInteractionElementInfo.element\r\n );\r\n \r\n if (exactMatch) {\r\n console.log('✅ Found exact element match:', {\r\n matchedElement: exactMatch.element?.tagName,\r\n matchedElementClass: exactMatch.element?.className,\r\n index: exactMatch.index\r\n });\r\n return exactMatch;\r\n }\r\n \r\n return null;\r\n }\r\n\r\n /**\r\n * Finds a match by traversing up the parent elements\r\n * \r\n * @param candidate_elements Array of candidate element infos\r\n * @param actualInteractionElementInfo The actual interaction element info\r\n * @returns The matching candidate element info, or null if no match is found\r\n */\r\n function findParentMatch(candidate_elements, actualInteractionElementInfo) {\r\n if (!actualInteractionElementInfo.element) {\r\n return null;\r\n }\r\n\r\n let element = actualInteractionElementInfo.element;\r\n while (element.parentElement) {\r\n element = element.parentElement;\r\n const parentMatch = candidate_elements.find(candidate => \r\n candidate.element && candidate.element === element\r\n );\r\n \r\n if (parentMatch) {\r\n console.log('✅ Found parent element match:', {\r\n matchedElement: parentMatch.element?.tagName,\r\n matchedElementClass: parentMatch.element?.className,\r\n index: parentMatch.index,\r\n depth: element.tagName\r\n });\r\n return parentMatch;\r\n }\r\n \r\n // Stop if we hit another candidate element\r\n if (candidate_elements.some(candidate => \r\n candidate.element && candidate.element === element\r\n )) {\r\n console.log('⚠️ Stopped parent search - hit another candidate element:', element.tagName);\r\n break;\r\n }\r\n }\r\n \r\n return null;\r\n }\r\n\r\n /**\r\n * Finds a match based on spatial relationships between elements\r\n * \r\n * @param candidate_elements Array of candidate element infos\r\n * @param actualInteractionElementInfo The actual interaction element info\r\n * @returns The matching candidate element info, or null if no match is found\r\n */\r\n function findSpatialMatch(candidate_elements, actualInteractionElementInfo) {\r\n if (!actualInteractionElementInfo.element || !actualInteractionElementInfo.bounding_box) {\r\n return null;\r\n }\r\n\r\n const actualBox = actualInteractionElementInfo.bounding_box;\r\n let bestMatch = null;\r\n let bestScore = 0;\r\n\r\n for (const candidateInfo of candidate_elements) {\r\n if (!candidateInfo.bounding_box) continue;\r\n \r\n const candidateBox = candidateInfo.bounding_box;\r\n let score = 0;\r\n\r\n // Check if actual element is contained within candidate\r\n if (isPointInsideBox({ x: actualBox.x, y: actualBox.y }, candidateBox) &&\r\n isPointInsideBox({ x: actualBox.x + actualBox.width, y: actualBox.y + actualBox.height }, candidateBox)) {\r\n score += 100; // High score for containment\r\n }\r\n\r\n // Calculate overlap area as a factor\r\n const overlap = calculateOverlap(actualBox, candidateBox);\r\n score += overlap;\r\n\r\n // Consider proximity if no containment\r\n if (score === 0) {\r\n const distance = Math.sqrt(\r\n Math.pow((actualBox.x + actualBox.width/2) - (candidateBox.x + candidateBox.width/2), 2) +\r\n Math.pow((actualBox.y + actualBox.height/2) - (candidateBox.y + candidateBox.height/2), 2)\r\n );\r\n // Convert distance to a score (closer = higher score)\r\n score = 1000 / (distance + 1);\r\n }\r\n\r\n if (score > bestScore) {\r\n bestScore = score;\r\n bestMatch = candidateInfo;\r\n console.log('📏 New best spatial match:', {\r\n element: candidateInfo.element?.tagName,\r\n class: candidateInfo.element?.className,\r\n index: candidateInfo.index,\r\n score: score\r\n });\r\n }\r\n }\r\n\r\n if (bestMatch) {\r\n console.log('✅ Final spatial match selected:', {\r\n element: bestMatch.element?.tagName,\r\n class: bestMatch.element?.className,\r\n index: bestMatch.index,\r\n finalScore: bestScore\r\n });\r\n return bestMatch;\r\n }\r\n\r\n return null;\r\n }\r\n\r\n /**\r\n * Finds a matching candidate element for an actual interaction element\r\n * \r\n * @param candidate_elements Array of candidate element infos\r\n * @param actualInteractionElementInfo The actual interaction element info\r\n * @returns The matching candidate element info, or null if no match is found\r\n */\r\n function findMatchingCandidateElementInfo(candidate_elements, actualInteractionElementInfo) {\r\n if (!actualInteractionElementInfo.element || !actualInteractionElementInfo.bounding_box) {\r\n console.error('❌ Missing required properties in actualInteractionElementInfo');\r\n return null;\r\n }\r\n\r\n console.log('🔍 Starting element matching for:', {\r\n clickedElement: actualInteractionElementInfo.element.tagName,\r\n clickedElementClass: actualInteractionElementInfo.element.className,\r\n totalCandidates: candidate_elements.length\r\n });\r\n\r\n // First try exact element match\r\n const exactMatch = findExactMatch(candidate_elements, actualInteractionElementInfo);\r\n if (exactMatch) {\r\n return exactMatch;\r\n }\r\n console.log('❌ No exact element match found, trying parent matching...');\r\n\r\n // Try finding closest clickable parent\r\n const parentMatch = findParentMatch(candidate_elements, actualInteractionElementInfo);\r\n if (parentMatch) {\r\n return parentMatch;\r\n }\r\n console.log('❌ No parent match found, falling back to spatial matching...');\r\n\r\n // If no exact or parent match, look for spatial relationships\r\n const spatialMatch = findSpatialMatch(candidate_elements, actualInteractionElementInfo);\r\n if (spatialMatch) {\r\n return spatialMatch;\r\n }\r\n\r\n console.error('❌ No matching element found for actual interaction element:', actualInteractionElementInfo);\r\n return null;\r\n }\n\n const highlight = {\r\n execute: async function(elementTypes, handleScroll=false) {\r\n const elements = await findElements(elementTypes);\r\n highlightElements(elements, handleScroll);\r\n return elements;\r\n },\r\n\r\n unexecute: function(handleScroll=false) {\r\n unhighlightElements(handleScroll);\r\n },\r\n\r\n generateJSON: async function() {\r\n const json = {};\r\n\r\n // Capture viewport dimensions\r\n const viewportData = {\r\n width: window.innerWidth,\r\n height: window.innerHeight,\r\n documentWidth: document.documentElement.clientWidth,\r\n documentHeight: document.documentElement.clientHeight,\r\n timestamp: new Date().toISOString()\r\n };\r\n\r\n // Add viewport data to the JSON output\r\n json.viewport = viewportData;\r\n\r\n\r\n await Promise.all(Object.values(ElementTag).map(async elementType => {\r\n const elements = await findElements(elementType);\r\n json[elementType] = elements;\r\n }));\r\n\r\n // Serialize the JSON object\r\n const jsonString = JSON.stringify(json, null, 4); // Pretty print with 4 spaces\r\n\r\n console.log(`JSON: ${jsonString}`);\r\n return jsonString;\r\n },\r\n\r\n getElementInfo\r\n };\r\n\r\n\r\n function unhighlightElements(handleScroll=false) {\r\n const documents = getAllFrames();\r\n documents.forEach(doc => {\r\n const overlay = doc.getElementById('highlight-overlay');\r\n if (overlay) {\r\n if (handleScroll) {\r\n // Remove event listeners\r\n doc.removeEventListener('scroll', overlay.scrollHandler, true);\r\n doc.removeEventListener('resize', overlay.resizeHandler);\r\n }\r\n overlay.remove();\r\n }\r\n });\r\n }\r\n\r\n\r\n\r\n\r\n async function findElements(elementTypes, verbose=true) {\r\n const typesArray = Array.isArray(elementTypes) ? elementTypes : [elementTypes];\r\n console.log('Starting element search for types:', typesArray);\r\n\r\n const elements = [];\r\n typesArray.forEach(elementType => {\r\n if (elementType === ElementTag.FILLABLE) {\r\n elements.push(...findFillables());\r\n }\r\n if (elementType === ElementTag.SELECTABLE) {\r\n elements.push(...findDropdowns());\r\n }\r\n if (elementType === ElementTag.CLICKABLE) {\r\n elements.push(...findClickables());\r\n elements.push(...findToggles());\r\n elements.push(...findCheckables());\r\n }\r\n if (elementType === ElementTag.NON_INTERACTIVE_ELEMENT) {\r\n elements.push(...findNonInteractiveElements());\r\n }\r\n });\r\n\r\n // console.log('Before uniquify:', elements.length);\r\n const elementsWithInfo = elements.map((element, index) => \r\n getElementInfo(element, index)\r\n );\r\n\r\n \r\n \r\n const uniqueElements = uniquifyElements(elementsWithInfo);\r\n console.log(`Found ${uniqueElements.length} elements:`);\r\n \r\n // More comprehensive visibility check\r\n const visibleElements = uniqueElements.filter(elementInfo => {\r\n const el = elementInfo.element;\r\n const style = getComputedStyle(el);\r\n \r\n // Check various style properties that affect visibility\r\n if (style.display === 'none' || \r\n style.visibility === 'hidden') {\r\n return false;\r\n }\r\n \r\n // Check if element has non-zero dimensions\r\n const rect = el.getBoundingClientRect();\r\n if (rect.width === 0 || rect.height === 0) {\r\n return false;\r\n }\r\n \r\n // Check if element is within viewport\r\n if (rect.bottom < 0 || \r\n rect.top > window.innerHeight || \r\n rect.right < 0 || \r\n rect.left > window.innerWidth) {\r\n // Element is outside viewport, but still might be valid \r\n // if user scrolls to it, so we'll include it\r\n return true;\r\n }\r\n \r\n return true;\r\n });\r\n \r\n console.log(`Out of which ${visibleElements.length} elements are visible:`);\r\n if (verbose) {\r\n visibleElements.forEach(info => {\r\n console.log(`Element ${info.index}:`, info);\r\n });\r\n }\r\n \r\n return visibleElements;\r\n }\r\n\r\n // elements is an array of objects with index, xpath\r\n function highlightElements(elements, handleScroll=false) {\r\n // console.log('[highlightElements] called with', elements.length, 'elements');\r\n // Create overlay if it doesn't exist and store it in a dictionary\r\n const documents = getAllFrames(); \r\n let overlays = {};\r\n documents.forEach(doc => {\r\n let overlay = doc.getElementById('highlight-overlay');\r\n if (!overlay) {\r\n overlay = doc.createElement('div');\r\n overlay.id = 'highlight-overlay';\r\n overlay.style.cssText = `\r\n position: fixed;\r\n top: 0;\r\n left: 0;\r\n width: 100%;\r\n height: 100%;\r\n pointer-events: none;\r\n z-index: 2147483647;\r\n `;\r\n doc.body.appendChild(overlay);\r\n // console.log('[highlightElements] Created overlay in document:', doc);\r\n }\r\n overlays[doc.documentURI] = overlay;\r\n });\r\n \r\n\r\n const updateHighlights = (doc = null) => {\r\n if (doc) {\r\n overlays[doc.documentURI].innerHTML = '';\r\n } else {\r\n Object.values(overlays).forEach(overlay => { overlay.innerHTML = ''; });\r\n } \r\n elements.forEach((elementInfo, idx) => {\r\n //console.log(`[highlightElements] Processing element ${idx}:`, elementInfo.tag, elementInfo.css_selector, elementInfo.bounding_box);\r\n let element = elementInfo.element; //getElementByXPathOrCssSelector(elementInfo);\r\n if (!element) {\r\n element = getElementByXPathOrCssSelector(elementInfo);\r\n if (!element) {\r\n console.warn('[highlightElements] Could not find element for:', elementInfo);\r\n return;\r\n }\r\n }\r\n //if highlights requested for a specific doc, skip unrelated elements\r\n if (doc && element.ownerDocument !== doc) {\r\n console.log(\"[highlightElements] Skipped element since it doesn't belong to document\", doc);\r\n return;\r\n }\r\n const rect = element.getBoundingClientRect();\r\n if (rect.width === 0 || rect.height === 0) {\r\n console.warn('[highlightElements] Element has zero dimensions:', elementInfo);\r\n return;\r\n }\r\n // Create border highlight (red rectangle)\r\n // use ownerDocument to support iframes/frames\r\n const highlight = element.ownerDocument.createElement('div');\r\n highlight.style.cssText = `\r\n position: fixed;\r\n left: ${rect.x}px;\r\n top: ${rect.y}px;\r\n width: ${rect.width}px;\r\n height: ${rect.height}px;\r\n border: 1px solid rgb(255, 0, 0);\r\n transition: all 0.2s ease-in-out;\r\n `;\r\n // Create index label container - now positioned to the right and slightly up\r\n const labelContainer = element.ownerDocument.createElement('div');\r\n labelContainer.style.cssText = `\r\n position: absolute;\r\n right: -10px; /* Offset to the right */\r\n top: -10px; /* Offset upwards */\r\n padding: 4px;\r\n background-color: rgba(255, 255, 0, 0.6);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n `;\r\n const text = element.ownerDocument.createElement('span');\r\n text.style.cssText = `\r\n color: rgb(0, 0, 0, 0.8);\r\n font-family: 'Courier New', Courier, monospace;\r\n font-size: 12px;\r\n font-weight: bold;\r\n line-height: 1;\r\n `;\r\n text.textContent = elementInfo.index;\r\n labelContainer.appendChild(text);\r\n highlight.appendChild(labelContainer); \r\n overlays[element.ownerDocument.documentURI].appendChild(highlight);\r\n \r\n });\r\n };\r\n\r\n // Initial highlight\r\n updateHighlights();\r\n\r\n if (handleScroll) {\r\n documents.forEach(doc => {\r\n // Update highlights on scroll and resize\r\n console.log('registering scroll and resize handlers for document: ', doc);\r\n const scrollHandler = () => {\r\n requestAnimationFrame(() => updateHighlights(doc));\r\n };\r\n const resizeHandler = () => {\r\n updateHighlights(doc);\r\n };\r\n doc.addEventListener('scroll', scrollHandler, true);\r\n doc.addEventListener('resize', resizeHandler);\r\n // Store event handlers for cleanup\r\n overlays[doc.documentURI].scrollHandler = scrollHandler;\r\n overlays[doc.documentURI].resizeHandler = resizeHandler;\r\n }); \r\n }\r\n }\r\n\r\n // function unexecute() {\r\n // unhighlightElements();\r\n // }\r\n\r\n // Make it available globally for both Extension and Playwright\r\n if (typeof window !== 'undefined') {\r\n function stripElementRefs(elementInfo) {\r\n if (!elementInfo) return null;\r\n const { element, ...rest } = elementInfo;\r\n return rest;\r\n }\r\n\r\n window.ProboLabs = window.ProboLabs || {};\r\n\r\n // --- Caching State ---\r\n window.ProboLabs.candidates = [];\r\n window.ProboLabs.actual = null;\r\n window.ProboLabs.matchingCandidate = null;\r\n\r\n // --- Methods ---\r\n /**\r\n * Find and cache candidate elements of a given type (e.g., 'CLICKABLE').\r\n * NOTE: This function is async and must be awaited from Playwright/Node.\r\n */\r\n window.ProboLabs.findAndCacheCandidateElements = async function(elementType) {\r\n //console.log('[ProboLabs] findAndCacheCandidateElements called with:', elementType);\r\n const found = await findElements(elementType);\r\n window.ProboLabs.candidates = found;\r\n // console.log('[ProboLabs] candidates set to:', found, 'type:', typeof found, 'isArray:', Array.isArray(found));\r\n return found.length;\r\n };\r\n\r\n window.ProboLabs.findAndCacheActualElement = function(cssSelector, iframeSelector, isHover=false) {\r\n // console.log('[ProboLabs] findAndCacheActualElement called with:', cssSelector, iframeSelector);\r\n let el = findElement(document, iframeSelector, cssSelector);\r\n if(isHover) {\r\n const visibleElement = findClosestVisibleElement(el);\r\n if (visibleElement) {\r\n el = visibleElement;\r\n }\r\n }\r\n if (!el) {\r\n window.ProboLabs.actual = null;\r\n // console.log('[ProboLabs] actual set to null');\r\n return false;\r\n }\r\n window.ProboLabs.actual = getElementInfo(el, -1);\r\n // console.log('[ProboLabs] actual set to:', window.ProboLabs.actual);\r\n return true;\r\n };\r\n\r\n window.ProboLabs.findAndCacheMatchingCandidate = function() {\r\n // console.log('[ProboLabs] findAndCacheMatchingCandidate called');\r\n if (!window.ProboLabs.candidates.length || !window.ProboLabs.actual) {\r\n window.ProboLabs.matchingCandidate = null;\r\n // console.log('[ProboLabs] matchingCandidate set to null');\r\n return false;\r\n }\r\n window.ProboLabs.matchingCandidate = findMatchingCandidateElementInfo(window.ProboLabs.candidates, window.ProboLabs.actual);\r\n // console.log('[ProboLabs] matchingCandidate set to:', window.ProboLabs.matchingCandidate);\r\n return !!window.ProboLabs.matchingCandidate;\r\n };\r\n\r\n window.ProboLabs.highlightCachedElements = function(which) {\r\n let elements = [];\r\n if (which === 'candidates') elements = window.ProboLabs.candidates;\r\n if (which === 'actual' && window.ProboLabs.actual) elements = [window.ProboLabs.actual];\r\n if (which === 'matching' && window.ProboLabs.matchingCandidate) elements = [window.ProboLabs.matchingCandidate];\r\n console.log(`[ProboLabs] highlightCachedElements ${which} with ${elements.length} elements`);\r\n highlightElements(elements);\r\n };\r\n\r\n window.ProboLabs.unhighlight = function() {\r\n // console.log('[ProboLabs] unhighlight called');\r\n unhighlightElements();\r\n };\r\n\r\n window.ProboLabs.reset = function() {\r\n console.log('[ProboLabs] reset called');\r\n window.ProboLabs.candidates = [];\r\n window.ProboLabs.actual = null;\r\n window.ProboLabs.matchingCandidate = null;\r\n unhighlightElements();\r\n };\r\n\r\n window.ProboLabs.getCandidates = function() {\r\n // console.log('[ProboLabs] getCandidates called. candidates:', window.ProboLabs.candidates, 'type:', typeof window.ProboLabs.candidates, 'isArray:', Array.isArray(window.ProboLabs.candidates));\r\n const arr = Array.isArray(window.ProboLabs.candidates) ? window.ProboLabs.candidates : [];\r\n return arr.map(stripElementRefs);\r\n };\r\n window.ProboLabs.getActual = function() {\r\n return stripElementRefs(window.ProboLabs.actual);\r\n };\r\n window.ProboLabs.getMatchingCandidate = function() {\r\n return stripElementRefs(window.ProboLabs.matchingCandidate);\r\n };\r\n\r\n // Retain existing API for backward compatibility\r\n window.ProboLabs.ElementTag = ElementTag;\r\n window.ProboLabs.highlightElements = highlightElements;\r\n window.ProboLabs.unhighlightElements = unhighlightElements;\r\n window.ProboLabs.findElements = findElements;\r\n window.ProboLabs.getElementInfo = getElementInfo;\r\n window.ProboLabs.highlight = window.ProboLabs.highlight;\r\n window.ProboLabs.unhighlight = window.ProboLabs.unhighlight;\r\n\r\n // --- Utility Functions ---\r\n function findClosestVisibleElement(element) {\r\n let current = element;\r\n while (current) {\r\n const style = window.getComputedStyle(current);\r\n if (\r\n style &&\r\n style.display !== 'none' &&\r\n style.visibility !== 'hidden' &&\r\n current.offsetWidth > 0 &&\r\n current.offsetHeight > 0\r\n ) {\r\n return current;\r\n }\r\n if (!current.parentElement || current === document.body) break;\r\n current = current.parentElement;\r\n }\r\n return null;\r\n }\r\n }\n\n exports.ElementInfo = ElementInfo;\n exports.ElementTag = ElementTag;\n exports.deserializeNodeFromJSON = deserializeNodeFromJSON;\n exports.detectScrollableContainers = detectScrollableContainers;\n exports.findElement = findElement;\n exports.findElements = findElements;\n exports.generateCssPath = generateCssPath;\n exports.getAriaLabelledByText = getAriaLabelledByText;\n exports.getContainingIframe = getContainingIframe;\n exports.getElementInfo = getElementInfo;\n exports.getParentNode = getParentNode;\n exports.getRobustSelector = getRobustSelector;\n exports.highlight = highlight;\n exports.highlightElements = highlightElements;\n exports.isScrollableContainer = isScrollableContainer;\n exports.serializeNodeToJSON = serializeNodeToJSON;\n exports.unhighlightElements = unhighlightElements;\n\n}));\n//# sourceMappingURL=probolabs.umd.js.map\n";
|
|
1
|
+
const highlighterCode = "(function (global, factory) {\n typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :\n typeof define === 'function' && define.amd ? define(['exports'], factory) :\n (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ProboLabs = {}));\n})(this, (function (exports) { 'use strict';\n\n const ElementTag = {\n CLICKABLE: \"CLICKABLE\", // button, link, toggle switch, checkbox, radio, dropdowns, clickable divs\n FILLABLE: \"FILLABLE\", // input, textarea content_editable, date picker??\n SELECTABLE: \"SELECTABLE\", // select\n NON_INTERACTIVE_ELEMENT: 'NON_INTERACTIVE_ELEMENT',\n };\n\n class ElementInfo {\n constructor(element, index, {tag, type, text, html, xpath, css_selector, bounding_box, iframe_selector, short_css_selector, short_iframe_selector}) {\n this.index = index.toString();\n this.tag = tag;\n this.type = type;\n this.text = text;\n this.html = html;\n this.xpath = xpath;\n this.css_selector = css_selector;\n this.bounding_box = bounding_box;\n this.iframe_selector = iframe_selector;\n this.element = element;\n this.depth = -1;\n this.short_css_selector = short_css_selector;\n this.short_iframe_selector = short_iframe_selector;\n }\n\n getSelector() {\n return this.xpath ? this.xpath : this.css_selector;\n }\n\n getDepth() {\n if (this.depth >= 0) {\n return this.depth;\n }\n \n this.depth = 0;\n let currentElement = this.element;\n \n while (currentElement.nodeType === Node.ELEMENT_NODE) { \n this.depth++;\n currentElement = getParentNode(currentElement);\n }\n \n return this.depth;\n }\n }\n\n function getParentNode(element) {\n if (!element || element.nodeType !== Node.ELEMENT_NODE) return null;\n \n let parent = null;\n // SF is using slots and shadow DOM heavily\n // However, there might be slots in the light DOM which shouldn't be traversed\n if (element.assignedSlot && element.getRootNode() instanceof ShadowRoot)\n parent = element.assignedSlot;\n else \n parent = element.parentNode;\n \n // Check if we're at a shadow root\n if (parent && parent.nodeType !== Node.ELEMENT_NODE && parent.getRootNode() instanceof ShadowRoot) \n parent = parent.getRootNode().host; \n\n return parent;\n }\n\n // License: MIT\n // Author: Anton Medvedev <anton@medv.io>\n // Source: https://github.com/antonmedv/finder\n const acceptedAttrNames = new Set(['role', 'name', 'aria-label', 'rel', 'href']);\n /** Check if attribute name and value are word-like. */\n function attr(name, value) {\n let nameIsOk = acceptedAttrNames.has(name);\n nameIsOk ||= name.startsWith('data-') && wordLike(name);\n let valueIsOk = wordLike(value) && value.length < 100;\n valueIsOk ||= value.startsWith('#') && wordLike(value.slice(1));\n return nameIsOk && valueIsOk;\n }\n /** Check if id name is word-like. */\n function idName(name) {\n return wordLike(name);\n }\n /** Check if class name is word-like. */\n function className(name) {\n return wordLike(name);\n }\n /** Check if tag name is word-like. */\n function tagName(name) {\n return true;\n }\n /** Finds unique CSS selectors for the given element. */\n function finder(input, options) {\n if (input.nodeType !== Node.ELEMENT_NODE) {\n throw new Error(`Can't generate CSS selector for non-element node type.`);\n }\n if (input.tagName.toLowerCase() === 'html') {\n return 'html';\n }\n const defaults = {\n root: document.body,\n idName: idName,\n className: className,\n tagName: tagName,\n attr: attr,\n timeoutMs: 1000,\n seedMinLength: 3,\n optimizedMinLength: 2,\n maxNumberOfPathChecks: Infinity,\n };\n const startTime = new Date();\n const config = { ...defaults, ...options };\n const rootDocument = findRootDocument(config.root, defaults);\n let foundPath;\n let count = 0;\n for (const candidate of search(input, config, rootDocument)) {\n const elapsedTimeMs = new Date().getTime() - startTime.getTime();\n if (elapsedTimeMs > config.timeoutMs ||\n count >= config.maxNumberOfPathChecks) {\n const fPath = fallback(input, rootDocument);\n if (!fPath) {\n throw new Error(`Timeout: Can't find a unique selector after ${config.timeoutMs}ms`);\n }\n return selector(fPath);\n }\n count++;\n if (unique(candidate, rootDocument)) {\n foundPath = candidate;\n break;\n }\n }\n if (!foundPath) {\n throw new Error(`Selector was not found.`);\n }\n const optimized = [\n ...optimize(foundPath, input, config, rootDocument, startTime),\n ];\n optimized.sort(byPenalty);\n if (optimized.length > 0) {\n return selector(optimized[0]);\n }\n return selector(foundPath);\n }\n function* search(input, config, rootDocument) {\n const stack = [];\n let paths = [];\n let current = input;\n let i = 0;\n while (current && current !== rootDocument) {\n const level = tie(current, config);\n for (const node of level) {\n node.level = i;\n }\n stack.push(level);\n current = current.parentElement;\n i++;\n paths.push(...combinations(stack));\n if (i >= config.seedMinLength) {\n paths.sort(byPenalty);\n for (const candidate of paths) {\n yield candidate;\n }\n paths = [];\n }\n }\n paths.sort(byPenalty);\n for (const candidate of paths) {\n yield candidate;\n }\n }\n function wordLike(name) {\n if (/^[a-z\\-]{3,}$/i.test(name)) {\n const words = name.split(/-|[A-Z]/);\n for (const word of words) {\n if (word.length <= 2) {\n return false;\n }\n if (/[^aeiou]{4,}/i.test(word)) {\n return false;\n }\n }\n return true;\n }\n return false;\n }\n function tie(element, config) {\n const level = [];\n const elementId = element.getAttribute('id');\n if (elementId && config.idName(elementId)) {\n level.push({\n name: '#' + CSS.escape(elementId),\n penalty: 0,\n });\n }\n for (let i = 0; i < element.classList.length; i++) {\n const name = element.classList[i];\n if (config.className(name)) {\n level.push({\n name: '.' + CSS.escape(name),\n penalty: 1,\n });\n }\n }\n for (let i = 0; i < element.attributes.length; i++) {\n const attr = element.attributes[i];\n if (config.attr(attr.name, attr.value)) {\n level.push({\n name: `[${CSS.escape(attr.name)}=\"${CSS.escape(attr.value)}\"]`,\n penalty: 2,\n });\n }\n }\n const tagName = element.tagName.toLowerCase();\n if (config.tagName(tagName)) {\n level.push({\n name: tagName,\n penalty: 5,\n });\n const index = indexOf(element, tagName);\n if (index !== undefined) {\n level.push({\n name: nthOfType(tagName, index),\n penalty: 10,\n });\n }\n }\n const nth = indexOf(element);\n if (nth !== undefined) {\n level.push({\n name: nthChild(tagName, nth),\n penalty: 50,\n });\n }\n return level;\n }\n function selector(path) {\n let node = path[0];\n let query = node.name;\n for (let i = 1; i < path.length; i++) {\n const level = path[i].level || 0;\n if (node.level === level - 1) {\n query = `${path[i].name} > ${query}`;\n }\n else {\n query = `${path[i].name} ${query}`;\n }\n node = path[i];\n }\n return query;\n }\n function penalty(path) {\n return path.map((node) => node.penalty).reduce((acc, i) => acc + i, 0);\n }\n function byPenalty(a, b) {\n return penalty(a) - penalty(b);\n }\n function indexOf(input, tagName) {\n const parent = input.parentNode;\n if (!parent) {\n return undefined;\n }\n let child = parent.firstChild;\n if (!child) {\n return undefined;\n }\n let i = 0;\n while (child) {\n if (child.nodeType === Node.ELEMENT_NODE &&\n (tagName === undefined ||\n child.tagName.toLowerCase() === tagName)) {\n i++;\n }\n if (child === input) {\n break;\n }\n child = child.nextSibling;\n }\n return i;\n }\n function fallback(input, rootDocument) {\n let i = 0;\n let current = input;\n const path = [];\n while (current && current !== rootDocument) {\n const tagName = current.tagName.toLowerCase();\n const index = indexOf(current, tagName);\n if (index === undefined) {\n return;\n }\n path.push({\n name: nthOfType(tagName, index),\n penalty: NaN,\n level: i,\n });\n current = current.parentElement;\n i++;\n }\n if (unique(path, rootDocument)) {\n return path;\n }\n }\n function nthChild(tagName, index) {\n if (tagName === 'html') {\n return 'html';\n }\n return `${tagName}:nth-child(${index})`;\n }\n function nthOfType(tagName, index) {\n if (tagName === 'html') {\n return 'html';\n }\n return `${tagName}:nth-of-type(${index})`;\n }\n function* combinations(stack, path = []) {\n if (stack.length > 0) {\n for (let node of stack[0]) {\n yield* combinations(stack.slice(1, stack.length), path.concat(node));\n }\n }\n else {\n yield path;\n }\n }\n function findRootDocument(rootNode, defaults) {\n if (rootNode.nodeType === Node.DOCUMENT_NODE) {\n return rootNode;\n }\n if (rootNode === defaults.root) {\n return rootNode.ownerDocument;\n }\n return rootNode;\n }\n function unique(path, rootDocument) {\n const css = selector(path);\n switch (rootDocument.querySelectorAll(css).length) {\n case 0:\n throw new Error(`Can't select any node with this selector: ${css}`);\n case 1:\n return true;\n default:\n return false;\n }\n }\n function* optimize(path, input, config, rootDocument, startTime) {\n if (path.length > 2 && path.length > config.optimizedMinLength) {\n for (let i = 1; i < path.length - 1; i++) {\n const elapsedTimeMs = new Date().getTime() - startTime.getTime();\n if (elapsedTimeMs > config.timeoutMs) {\n return;\n }\n const newPath = [...path];\n newPath.splice(i, 1);\n if (unique(newPath, rootDocument) &&\n rootDocument.querySelector(selector(newPath)) === input) {\n yield newPath;\n yield* optimize(newPath, input, config, rootDocument, startTime);\n }\n }\n }\n }\n\n // import { realpath } from \"fs\";\n\n function getAllDocumentElementsIncludingShadow(selectors, root = document) {\n const elements = Array.from(root.querySelectorAll(selectors));\n\n root.querySelectorAll('*').forEach(el => {\n if (el.shadowRoot) {\n elements.push(...getAllDocumentElementsIncludingShadow(selectors, el.shadowRoot));\n }\n });\n return elements;\n }\n\n function getAllFrames(root = document) {\n const result = [root];\n const frames = getAllDocumentElementsIncludingShadow('frame, iframe', root); \n frames.forEach(frame => {\n try {\n const frameDocument = frame.contentDocument || frame.contentWindow.document;\n if (frameDocument) {\n result.push(frameDocument);\n }\n } catch (e) {\n // Skip cross-origin frames\n console.warn('Could not access frame content:', e.message);\n }\n });\n\n return result;\n }\n\n function getAllElementsIncludingShadow(selectors, root = document) {\n const elements = [];\n\n getAllFrames(root).forEach(doc => {\n elements.push(...getAllDocumentElementsIncludingShadow(selectors, doc));\n });\n\n return elements;\n }\n\n /**\n * Deeply searches through DOM trees including Shadow DOM and frames/iframes\n * @param {string} selector - CSS selector to search for\n * @param {Document|Element} [root=document] - Starting point for the search\n * @param {Object} [options] - Search options\n * @param {boolean} [options.searchShadow=true] - Whether to search Shadow DOM\n * @param {boolean} [options.searchFrames=true] - Whether to search frames/iframes\n * @returns {Element[]} Array of found elements\n \n function getAllElementsIncludingShadow(selector, root = document, options = {}) {\n const {\n searchShadow = true,\n searchFrames = true\n } = options;\n\n const results = new Set();\n \n // Helper to check if an element is valid and not yet found\n const addIfValid = (element) => {\n if (element && !results.has(element)) {\n results.add(element);\n }\n };\n\n // Helper to process a single document or element\n function processNode(node) {\n // Search regular DOM\n node.querySelectorAll(selector).forEach(addIfValid);\n\n if (searchShadow) {\n // Search all shadow roots\n const treeWalker = document.createTreeWalker(\n node,\n NodeFilter.SHOW_ELEMENT,\n {\n acceptNode: (element) => {\n return element.shadowRoot ? \n NodeFilter.FILTER_ACCEPT : \n NodeFilter.FILTER_SKIP;\n }\n }\n );\n\n while (treeWalker.nextNode()) {\n const element = treeWalker.currentNode;\n if (element.shadowRoot) {\n // Search within shadow root\n element.shadowRoot.querySelectorAll(selector).forEach(addIfValid);\n // Recursively process the shadow root for nested shadow DOMs\n processNode(element.shadowRoot);\n }\n }\n }\n\n if (searchFrames) {\n // Search frames and iframes\n const frames = node.querySelectorAll('frame, iframe');\n frames.forEach(frame => {\n try {\n const frameDocument = frame.contentDocument;\n if (frameDocument) {\n processNode(frameDocument);\n }\n } catch (e) {\n // Skip cross-origin frames\n console.warn('Could not access frame content:', e.message);\n }\n });\n }\n }\n\n // Start processing from the root\n processNode(root);\n\n return Array.from(results);\n }\n */\n // <div x=1 y=2 role='combobox'> </div>\n function findDropdowns() {\n const dropdowns = [];\n \n // Native select elements\n dropdowns.push(...getAllElementsIncludingShadow('select'));\n \n // Elements with dropdown roles that don't have <input>..</input>\n const roleElements = getAllElementsIncludingShadow('[role=\"combobox\"], [role=\"listbox\"], [role=\"dropdown\"], [role=\"option\"], [role=\"menu\"], [role=\"menuitem\"]').filter(el => {\n return el.tagName.toLowerCase() !== 'input' || ![\"button\", \"checkbox\", \"radio\"].includes(el.getAttribute(\"type\"));\n });\n dropdowns.push(...roleElements);\n \n // Common dropdown class patterns\n const dropdownPattern = /.*(dropdown|select|combobox|menu).*/i;\n const elements = getAllElementsIncludingShadow('*');\n const dropdownClasses = Array.from(elements).filter(el => {\n const hasDropdownClass = dropdownPattern.test(el.className);\n const validTag = ['li', 'ul', 'span', 'div', 'p', 'a', 'button'].includes(el.tagName.toLowerCase());\n const style = window.getComputedStyle(el); \n const result = hasDropdownClass && validTag && (style.cursor === 'pointer' || el.tagName.toLowerCase() === 'a' || el.tagName.toLowerCase() === 'button');\n return result;\n });\n \n dropdowns.push(...dropdownClasses);\n \n // Elements with aria-haspopup attribute\n dropdowns.push(...getAllElementsIncludingShadow('[aria-haspopup=\"true\"], [aria-haspopup=\"listbox\"], [aria-haspopup=\"menu\"]'));\n\n // Improve navigation element detection\n // Semantic nav elements with list items\n dropdowns.push(...getAllElementsIncludingShadow('nav ul li, nav ol li'));\n \n // Navigation elements in common design patterns\n dropdowns.push(...getAllElementsIncludingShadow('header a, .header a, .nav a, .navigation a, .menu a, .sidebar a, aside a'));\n \n // Elements in primary navigation areas with common attributes\n dropdowns.push(...getAllElementsIncludingShadow('[role=\"navigation\"] a, [aria-label*=\"navigation\"] a, [aria-label*=\"menu\"] a'));\n\n return dropdowns;\n }\n\n function findClickables() {\n const clickables = [];\n \n const checkboxPattern = /checkbox/i;\n // Collect all clickable elements first\n const nativeLinks = [...getAllElementsIncludingShadow('a')];\n const nativeButtons = [...getAllElementsIncludingShadow('button')];\n const inputButtons = [...getAllElementsIncludingShadow('input[type=\"button\"], input[type=\"submit\"], input[type=\"reset\"]')];\n const roleButtons = [...getAllElementsIncludingShadow('[role=\"button\"]')];\n // const tabbable = [...getAllElementsIncludingShadow('[tabindex=\"0\"]')];\n const clickHandlers = [...getAllElementsIncludingShadow('[onclick]')];\n const dropdowns = findDropdowns();\n const nativeCheckboxes = [...getAllElementsIncludingShadow('input[type=\"checkbox\"]')]; \n const fauxCheckboxes = getAllElementsIncludingShadow('*').filter(el => {\n if (checkboxPattern.test(el.className)) {\n const realCheckboxes = getAllElementsIncludingShadow('input[type=\"checkbox\"]', el);\n if (realCheckboxes.length === 1) {\n const boundingRect = realCheckboxes[0].getBoundingClientRect();\n return boundingRect.width <= 1 && boundingRect.height <= 1 \n }\n }\n return false;\n });\n const nativeRadios = [...getAllElementsIncludingShadow('input[type=\"radio\"]')];\n const toggles = findToggles();\n const pointerElements = findElementsWithPointer();\n // Add all elements at once\n clickables.push(\n ...nativeLinks,\n ...nativeButtons,\n ...inputButtons,\n ...roleButtons,\n // ...tabbable,\n ...clickHandlers,\n ...dropdowns,\n ...nativeCheckboxes,\n ...fauxCheckboxes,\n ...nativeRadios,\n ...toggles,\n ...pointerElements\n );\n\n // Only uniquify once at the end\n return clickables; // Let findElements handle the uniquification\n }\n\n function findToggles() {\n const toggles = [];\n const checkboxes = getAllElementsIncludingShadow('input[type=\"checkbox\"]');\n const togglePattern = /switch|toggle|slider/i;\n\n checkboxes.forEach(checkbox => {\n let isToggle = false;\n\n // Check the checkbox itself\n if (togglePattern.test(checkbox.className) || togglePattern.test(checkbox.getAttribute('role') || '')) {\n isToggle = true;\n }\n\n // Check parent elements (up to 3 levels)\n if (!isToggle) {\n let element = checkbox;\n for (let i = 0; i < 3; i++) {\n const parent = element.parentElement;\n if (!parent) break;\n\n const className = parent.className || '';\n const role = parent.getAttribute('role') || '';\n\n if (togglePattern.test(className) || togglePattern.test(role)) {\n isToggle = true;\n break;\n }\n element = parent;\n }\n }\n\n // Check next sibling\n if (!isToggle) {\n const nextSibling = checkbox.nextElementSibling;\n if (nextSibling) {\n const className = nextSibling.className || '';\n const role = nextSibling.getAttribute('role') || '';\n if (togglePattern.test(className) || togglePattern.test(role)) {\n isToggle = true;\n }\n }\n }\n\n if (isToggle) {\n toggles.push(checkbox);\n }\n });\n\n return toggles;\n }\n\n function findNonInteractiveElements() {\n // Get all elements in the document\n const all = Array.from(getAllElementsIncludingShadow('*'));\n \n // Filter elements based on Python implementation rules\n return all.filter(element => {\n if (!element.firstElementChild) {\n const tag = element.tagName.toLowerCase(); \n if (!['select', 'button', 'a'].includes(tag)) {\n const validTags = ['p', 'span', 'div', 'input', 'textarea','td','th'].includes(tag) || /^h\\d$/.test(tag) || /text/.test(tag);\n const boundingRect = element.getBoundingClientRect();\n return validTags && boundingRect.height > 1 && boundingRect.width > 1;\n }\n }\n return false;\n });\n }\n\n\n\n // export function findNonInteractiveElements() {\n // const all = [];\n // try {\n // const elements = getAllElementsIncludingShadow('*');\n // all.push(...elements);\n // } catch (e) {\n // console.warn('Error getting elements:', e);\n // }\n \n // console.debug('Total elements found:', all.length);\n \n // return all.filter(element => {\n // try {\n // const tag = element.tagName.toLowerCase(); \n\n // // Special handling for input elements\n // if (tag === 'input' || tag === 'textarea') {\n // const boundingRect = element.getBoundingClientRect();\n // const value = element.value || '';\n // const placeholder = element.placeholder || '';\n // return boundingRect.height > 1 && \n // boundingRect.width > 1 && \n // (value.trim() !== '' || placeholder.trim() !== '');\n // }\n\n \n // // Check if it's a valid tag for text content\n // const validTags = ['p', 'span', 'div', 'label', 'th', 'td', 'li', 'button', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'select'].includes(tag) || \n // /^h\\d$/.test(tag) || \n // /text/.test(tag);\n\n // const boundingRect = element.getBoundingClientRect();\n\n // // Get direct text content, excluding child element text\n // let directText = '';\n // for (const node of element.childNodes) {\n // // Only include text nodes (nodeType 3)\n // if (node.nodeType === 3) {\n // directText += node.textContent || '';\n // }\n // }\n \n // // If no direct text and it's a table cell or heading, check label content\n // if (!directText.trim() && (tag === 'th' || tag === 'td' || tag === 'h1')) {\n // const labels = element.getElementsByTagName('label');\n // for (const label of labels) {\n // directText += label.textContent || '';\n // }\n // }\n\n // // If still no text and it's a heading, get all text content\n // if (!directText.trim() && tag === 'h1') {\n // directText = element.textContent || '';\n // }\n\n // directText = directText.trim();\n\n // // Debug logging\n // if (directText) {\n // console.debugg('Text element found:', {\n // tag,\n // text: directText,\n // dimensions: boundingRect,\n // element\n // });\n // }\n\n // return validTags && \n // boundingRect.height > 1 && \n // boundingRect.width > 1 && \n // directText !== '';\n \n // } catch (e) {\n // console.warn('Error processing element:', e);\n // return false;\n // }\n // });\n // }\n\n\n\n\n\n function findElementsWithPointer() {\n const elements = [];\n const allElements = getAllElementsIncludingShadow('*');\n \n console.log('Checking elements with pointer style...');\n \n allElements.forEach(element => {\n // Skip SVG elements for now\n if (element instanceof SVGElement || element.tagName.toLowerCase() === 'svg') {\n return;\n }\n \n const style = window.getComputedStyle(element);\n if (style.cursor === 'pointer') {\n elements.push(element);\n }\n });\n \n console.log(`Found ${elements.length} elements with pointer cursor`);\n return elements;\n }\n\n function findCheckables() {\n const elements = [];\n\n elements.push(...getAllElementsIncludingShadow('input[type=\"checkbox\"]'));\n elements.push(...getAllElementsIncludingShadow('input[type=\"radio\"]'));\n const all_elements = getAllElementsIncludingShadow('label');\n const radioClasses = Array.from(all_elements).filter(el => {\n return /.*radio.*/i.test(el.className); \n });\n elements.push(...radioClasses);\n return elements;\n }\n\n function findFillables() {\n const elements = [];\n\n const inputs = [...getAllElementsIncludingShadow('input:not([type=\"radio\"]):not([type=\"checkbox\"])')];\n console.log('Found inputs:', inputs.length, inputs);\n elements.push(...inputs);\n \n const textareas = [...getAllElementsIncludingShadow('textarea')];\n console.log('Found textareas:', textareas.length);\n elements.push(...textareas);\n \n const editables = [...getAllElementsIncludingShadow('[contenteditable=\"true\"]')];\n console.log('Found editables:', editables.length);\n elements.push(...editables);\n\n return elements;\n }\n\n // Helper function to check if element is a form control\n function isFormControl(elementInfo) {\n return /^(input|select|textarea|button|label)$/i.test(elementInfo.tag);\n }\n\n const isDropdownItem = (elementInfo) => {\n const dropdownPatterns = [\n /dropdown[-_]?item/i, // matches: dropdown-item, dropdownitem, dropdown_item\n /menu[-_]?item/i, // matches: menu-item, menuitem, menu_item\n /dropdown[-_]?link/i, // matches: dropdown-link, dropdownlink, dropdown_link\n /list[-_]?item/i, // matches: list-item, listitem, list_item\n /select[-_]?item/i, // matches: select-item, selectitem, select_item \n ];\n\n const rolePatterns = [\n /menu[-_]?item/i, // matches: menuitem, menu-item\n /option/i, // matches: option\n /list[-_]?item/i, // matches: listitem, list-item\n /tree[-_]?item/i // matches: treeitem, tree-item\n ];\n\n const hasMatchingClass = elementInfo.element.className && \n dropdownPatterns.some(pattern => \n pattern.test(elementInfo.element.className)\n );\n\n const hasMatchingRole = elementInfo.element.getAttribute('role') && \n rolePatterns.some(pattern => \n pattern.test(elementInfo.element.getAttribute('role'))\n );\n\n return hasMatchingClass || hasMatchingRole;\n };\n\n /**\n * Finds the first element matching a CSS selector, traversing Shadow DOM if necessary\n * @param {string} selector - CSS selector to search for\n * @param {Element} [root=document] - Root element to start searching from\n * @returns {Element|null} - The first matching element or null if not found\n */\n function querySelectorShadow(selector, root = document) {\n // First try to find in light DOM\n let element = root.querySelector(selector);\n if (element) return element;\n \n // Get all elements with shadow root\n const shadowElements = Array.from(root.querySelectorAll('*'))\n .filter(el => el.shadowRoot);\n \n // Search through each shadow root until we find a match\n for (const el of shadowElements) {\n element = querySelectorShadow(selector, el.shadowRoot);\n if (element) return element;\n }\n \n return null;\n }\n\n const getElementByXPathOrCssSelector = (element_info) => {\n console.log('getElementByXPathOrCssSelector:', element_info);\n\n findElement(document, element_info.iframe_selector, element_info.css_selector);\n };\n\n const findElement = (root, iframeSelector, cssSelector) => {\n let element;\n \n if (iframeSelector) { \n const frames = getAllDocumentElementsIncludingShadow('iframe', root);\n \n // Iterate over all frames and compare their CSS selectors\n for (const frame of frames) {\n const selector = generateCssPath(frame);\n if (selector === iframeSelector) {\n const frameDocument = frame.contentDocument || frame.contentWindow.document;\n element = querySelectorShadow(cssSelector, frameDocument);\n console.log('found element ', element);\n break;\n } \n } }\n else\n element = querySelectorShadow(cssSelector, root);\n \n if (!element) {\n console.warn('Failed to find element with CSS selector:', cssSelector);\n }\n\n return element;\n };\n\n\n function isDecendent(parent, child) {\n let element = child;\n while (element && element !== parent && element.nodeType === Node.ELEMENT_NODE) { \n element = getParentNode(element); \n }\n return element === parent;\n }\n\n function generateXPath(element) {\n return '/'+extractElementPath(element).map(item => `${item.tagName}${item.onlyChild ? '' : `[${item.index}]`}`).join('/');\n }\n\n function generateCssPath(element) {\n return extractElementPath(element).map(item => `${item.tagName}:nth-of-type(${item.index})`).join(' > ');\n }\n\n function extractElementPath(element) {\n if (!element) {\n console.error('ERROR: No element provided to generatePath');\n return [];\n }\n const path = [];\n // traversing up the DOM tree\n while (element && element.nodeType === Node.ELEMENT_NODE) { \n let tagName = element.nodeName.toLowerCase();\n \n let sibling = element;\n let index = 1;\n \n while (sibling = sibling.previousElementSibling) {\n if (sibling.nodeName.toLowerCase() === tagName) index++;\n }\n sibling = element;\n \n let onlyChild = (index === 1);\n while (onlyChild && (sibling = sibling.nextElementSibling)) {\n if (sibling.nodeName.toLowerCase() === tagName) onlyChild = false;\n }\n \n // add a tuple with tagName, index (nth), and onlyChild \n path.unshift({\n tagName: tagName,\n index: index,\n onlyChild: onlyChild \n }); \n\n element = getParentNode(element);\n }\n \n return path;\n }\n\n function cleanHTML(rawHTML) {\n const parser = new DOMParser();\n const doc = parser.parseFromString(rawHTML, \"text/html\");\n\n function cleanElement(element) {\n const allowedAttributes = new Set([\n \"role\",\n \"type\",\n \"class\",\n \"href\",\n \"alt\",\n \"title\",\n \"readonly\",\n \"checked\",\n \"enabled\",\n \"disabled\",\n ]);\n\n [...element.attributes].forEach(attr => {\n const name = attr.name.toLowerCase();\n const value = attr.value;\n\n const isTestAttribute = /^(testid|test-id|data-test-id)$/.test(name);\n const isDataAttribute = name.startsWith(\"data-\") && value;\n const isBooleanAttribute = [\"readonly\", \"checked\", \"enabled\", \"disabled\"].includes(name);\n\n if (!allowedAttributes.has(name) && !isDataAttribute && !isTestAttribute && !isBooleanAttribute) {\n element.removeAttribute(name);\n }\n });\n\n // Handle SVG content - more aggressive replacement\n if (element.tagName.toLowerCase() === \"svg\") {\n // Remove all attributes except class and role\n [...element.attributes].forEach(attr => {\n const name = attr.name.toLowerCase();\n if (name !== \"class\" && name !== \"role\") {\n element.removeAttribute(name);\n }\n });\n element.innerHTML = \"CONTENT REMOVED\";\n } else {\n // Recursively clean child elements\n Array.from(element.children).forEach(cleanElement);\n }\n\n // Only remove empty elements that aren't semantic or icon elements\n const keepEmptyElements = ['i', 'span', 'svg', 'button', 'input'];\n if (!keepEmptyElements.includes(element.tagName.toLowerCase()) && \n !element.children.length && \n !element.textContent.trim()) {\n element.remove();\n }\n }\n\n // Process all elements in the document body\n Array.from(doc.body.children).forEach(cleanElement);\n return doc.body.innerHTML;\n }\n\n function getContainingIframe(element) {\n // If not in an iframe, return null\n if (element.ownerDocument.defaultView === window.top) {\n return null;\n }\n \n // Try to find the iframe in the parent document that contains our element\n try {\n const parentDocument = element.ownerDocument.defaultView.parent.document;\n const iframes = parentDocument.querySelectorAll('iframe');\n \n for (const iframe of iframes) {\n if (iframe.contentWindow === element.ownerDocument.defaultView) {\n return iframe;\n }\n }\n } catch (e) {\n // Cross-origin restriction\n return \"Cross-origin iframe - cannot access details\";\n }\n \n return null;\n }\n\n function getElementInfo(element, index) {\n // Get text content with spaces between elements\n /* function getTextContent(element) {\n const walker = document.createTreeWalker(\n element,\n NodeFilter.SHOW_TEXT,\n null,\n false\n );\n\n let text = '';\n let node;\n\n while (node = walker.nextNode()) {\n const trimmedText = node.textContent.trim();\n if (trimmedText) {\n // Add space if there's already text\n if (text) {\n text += ' ';\n }\n text += trimmedText;\n }\n }\n\n return text;\n } */\n\n const xpath = generateXPath(element);\n const css_selector = generateCssPath(element);\n //disabled since it's blocking event handling in recorder\n const short_css_selector = ''; //getRobustSelector(element);\n\n const iframe = getContainingIframe(element); \n const iframe_selector = iframe ? generateCssPath(iframe) : \"\";\n //disabled since it's blocking event handling in recorder\n const short_iframe_selector = ''; //iframe ? getRobustSelector(iframe) : \"\";\n\n // Return element info with pre-calculated values\n return new ElementInfo(element, index, {\n tag: element.tagName.toLowerCase(),\n type: element.type || '',\n text: element.innerText || element.placeholder || '', //getTextContent(element),\n html: cleanHTML(element.outerHTML),\n xpath: xpath,\n css_selector: css_selector,\n bounding_box: element.getBoundingClientRect(),\n iframe_selector: iframe_selector,\n short_css_selector: short_css_selector,\n short_iframe_selector: short_iframe_selector\n });\n }\n\n function getAriaLabelledByText(elementInfo, includeHidden=true) {\n if (!elementInfo.element.hasAttribute('aria-labelledby')) return '';\n\n const ids = elementInfo.element.getAttribute('aria-labelledby').split(/\\s+/);\n let labelText = '';\n\n //locate root (document or iFrame document if element is contained in an iframe)\n let root = document;\n if (elementInfo.iframe_selector) { \n const frames = getAllDocumentElementsIncludingShadow('iframe', document);\n \n // Iterate over all frames and compare their CSS selectors\n for (const frame of frames) {\n const selector = generateCssPath(frame);\n if (selector === elementInfo.iframe_selector) {\n root = frame.contentDocument || frame.contentWindow.document; \n break;\n }\n } \n }\n\n ids.forEach(id => {\n const el = querySelectorShadow(`#${CSS.escape(id)}`, root);\n if (el) {\n if (includeHidden || el.offsetParent !== null || getComputedStyle(el).display !== 'none') {\n labelText += el.textContent.trim() + ' ';\n }\n }\n });\n\n return labelText.trim();\n }\n\n\n\n const filterZeroDimensions = (elementInfo) => {\n const rect = elementInfo.bounding_box;\n //single pixel elements are typically faux controls and should be filtered too\n const hasSize = rect.width > 1 && rect.height > 1;\n const style = window.getComputedStyle(elementInfo.element);\n const isVisible = style.display !== 'none' && style.visibility !== 'hidden';\n \n if (!hasSize || !isVisible) {\n \n return false;\n }\n return true;\n };\n\n\n\n function uniquifyElements(elements) {\n const seen = new Set();\n\n console.log(`Starting uniquification with ${elements.length} elements`);\n\n // Filter out testing infrastructure elements first\n const filteredInfrastructure = elements.filter(element_info => {\n // Skip the highlight-overlay element completely - it's part of the testing infrastructure\n if (element_info.element.id === 'highlight-overlay' || \n (element_info.css_selector && element_info.css_selector.includes('#highlight-overlay'))) {\n console.log('Filtered out testing infrastructure element:', element_info.css_selector);\n return false;\n }\n \n // Filter out UI framework container/manager elements\n const el = element_info.element;\n // UI framework container checks - generic detection for any framework\n if ((el.getAttribute('data-rendered-by') || \n el.getAttribute('data-reactroot') || \n el.getAttribute('ng-version') || \n el.getAttribute('data-component-id') ||\n el.getAttribute('data-root') ||\n el.getAttribute('data-framework')) && \n (el.className && \n typeof el.className === 'string' && \n (el.className.includes('Container') || \n el.className.includes('container') || \n el.className.includes('Manager') || \n el.className.includes('manager')))) {\n console.log('Filtered out UI framework container element:', element_info.css_selector);\n return false;\n }\n \n // Direct filter for framework container elements that shouldn't be interactive\n // Consolidating multiple container detection patterns into one efficient check\n const isFullViewport = element_info.bounding_box && \n element_info.bounding_box.x <= 5 && \n element_info.bounding_box.y <= 5 && \n element_info.bounding_box.width >= (window.innerWidth * 0.95) && \n element_info.bounding_box.height >= (window.innerHeight * 0.95);\n \n // Empty content check\n const isEmpty = !el.innerText || el.innerText.trim() === '';\n \n // Check if it's a framework container element\n if (element_info.element.tagName === 'DIV' && \n isFullViewport && \n isEmpty && \n (\n // Pattern matching for root containers\n (element_info.xpath && \n (element_info.xpath.match(/^\\/html\\[\\d+\\]\\/body\\[\\d+\\]\\/div\\[\\d+\\]\\/div\\[\\d+\\]$/) || \n element_info.xpath.match(/^\\/\\/\\*\\[@id='[^']+'\\]\\/div\\[\\d+\\]$/))) ||\n \n // Simple DOM structure\n (element_info.css_selector.split(' > ').length <= 4 && element_info.depth <= 5) ||\n \n // Empty or container-like classes\n (!el.className || el.className === '' || \n (typeof el.className === 'string' && \n (el.className.includes('overlay') || \n el.className.includes('container') || \n el.className.includes('wrapper'))))\n )) {\n console.log('Filtered out framework container element:', element_info.css_selector);\n return false;\n }\n \n return true;\n });\n\n // First filter out elements with zero dimensions\n const nonZeroElements = filteredInfrastructure.filter(filterZeroDimensions);\n // sort by CSS selector depth so parents are processed first\n nonZeroElements.sort((a, b) => a.getDepth() - b.getDepth());\n console.log(`After dimension filtering: ${nonZeroElements.length} elements remain (${elements.length - nonZeroElements.length} removed)`);\n \n const filteredByParent = nonZeroElements.filter(element_info => {\n\n const parent = findClosestParent(seen, element_info);\n const keep = parent == null || shouldKeepNestedElement(element_info, parent);\n // console.log(\"node \", element_info.index, \": keep=\", keep, \" parent=\", parent);\n // if (!keep && !element_info.xpath) {\n // console.log(\"Filtered out element \", element_info,\" because it's a nested element of \", parent);\n // }\n if (keep)\n seen.add(element_info.css_selector);\n\n return keep;\n });\n\n console.log(`After parent/child filtering: ${filteredByParent.length} elements remain (${nonZeroElements.length - filteredByParent.length} removed)`);\n\n // Final overlap filtering\n const filteredResults = filteredByParent.filter(element => {\n\n // Look for any element that came BEFORE this one in the array\n const hasEarlierOverlap = filteredByParent.some(other => {\n // Only check elements that came before (lower index)\n if (filteredByParent.indexOf(other) >= filteredByParent.indexOf(element)) {\n return false;\n }\n \n const isOverlapping = areElementsOverlapping(element, other); \n return isOverlapping;\n }); \n\n // Keep element if it has no earlier overlapping elements\n return !hasEarlierOverlap;\n });\n \n \n \n // Check for overlay removal\n console.log(`After filtering: ${filteredResults.length} (${filteredByParent.length - filteredResults.length} removed by overlap)`);\n \n const nonOverlaidElements = filteredResults.filter(element => {\n return !isOverlaid(element);\n });\n\n console.log(`Final elements after overlay removal: ${nonOverlaidElements.length} (${filteredResults.length - nonOverlaidElements.length} removed)`);\n \n return nonOverlaidElements;\n\n }\n\n\n\n const areElementsOverlapping = (element1, element2) => {\n if (element1.css_selector === element2.css_selector) {\n return true;\n }\n \n const box1 = element1.bounding_box;\n const box2 = element2.bounding_box;\n \n return box1.x === box2.x &&\n box1.y === box2.y &&\n box1.width === box2.width &&\n box1.height === box2.height;\n // element1.text === element2.text &&\n // element2.tag === 'a';\n };\n\n function findClosestParent(seen, element_info) { \n \n // Split the xpath into segments\n const segments = element_info.css_selector.split(' > ');\n \n // Try increasingly shorter paths until we find one in the seen set\n for (let i = segments.length - 1; i > 0; i--) {\n const parentPath = segments.slice(0, i).join(' > ');\n if (seen.has(parentPath)) {\n return parentPath;\n }\n }\n\n return null;\n }\n\n function shouldKeepNestedElement(elementInfo, parentPath) {\n let result = false;\n const parentSegments = parentPath.split(' > ');\n\n const isParentLink = /^a(:nth-of-type\\(\\d+\\))?$/.test(parentSegments[parentSegments.length - 1]);\n if (isParentLink) {\n return false; \n }\n // If this is a checkbox/radio input\n if (elementInfo.tag === 'input' && \n (elementInfo.type === 'checkbox' || elementInfo.type === 'radio')) {\n \n // Check if parent is a label by looking at the parent xpath's last segment\n \n const isParentLabel = /^label(:nth-of-type\\(\\d+\\))?$/.test(parentSegments[parentSegments.length - 1]);\n \n // If parent is a label, don't keep the input (we'll keep the label instead)\n if (isParentLabel) {\n return false;\n }\n }\n \n // Keep all other form controls and dropdown items\n if (isFormControl(elementInfo) || isDropdownItem(elementInfo)) {\n result = true;\n }\n\n if(isTableCell(elementInfo)) {\n result = true;\n }\n \n \n // console.log(`shouldKeepNestedElement: ${elementInfo.tag} ${elementInfo.text} ${elementInfo.xpath} -> ${parentXPath} -> ${result}`);\n return result;\n }\n\n\n function isTableCell(elementInfo) {\n const element = elementInfo.element;\n if(!element || !(element instanceof HTMLElement)) {\n return false;\n }\n const validTags = new Set(['td', 'th']);\n const validRoles = new Set(['cell', 'gridcell', 'columnheader', 'rowheader']);\n \n const tag = element.tagName.toLowerCase();\n const role = element.getAttribute('role')?.toLowerCase();\n\n if (validTags.has(tag) || (role && validRoles.has(role))) {\n return true;\n }\n return false;\n \n }\n\n function isOverlaid(elementInfo) {\n const element = elementInfo.element;\n const boundingRect = elementInfo.bounding_box;\n \n\n \n \n // Create a diagnostic logging function that only logs when needed\n const diagnosticLog = (...args) => {\n { // set to true for debugging\n console.log('[OVERLAY-DEBUG]', ...args);\n }\n };\n\n // Special handling for tooltips\n if (elementInfo.element.className && typeof elementInfo.element.className === 'string' && \n elementInfo.element.className.includes('tooltip')) {\n diagnosticLog('Element is a tooltip, not considering it overlaid');\n return false;\n }\n \n \n \n // Get element at the center point to check if it's covered by a popup/modal\n const middleX = boundingRect.x + boundingRect.width/2;\n const middleY = boundingRect.y + boundingRect.height/2;\n const elementAtMiddle = element.ownerDocument.elementFromPoint(middleX, middleY);\n \n if (elementAtMiddle && \n elementAtMiddle !== element && \n !isDecendent(element, elementAtMiddle) && \n !isDecendent(elementAtMiddle, element)) {\n\n \n return true;\n }\n \n \n return false;\n \n }\n\n\n\n /**\n * Get the “best” short, unique, and robust CSS selector for an element.\n * \n * @param {Element} element\n * @returns {string} A selector guaranteed to find exactly that element in its context\n */\n function getRobustSelector(element) {\n // 1. Figure out the real “root” (iframe doc, shadow root, or main doc)\n const root = (() => {\n const rootNode = element.getRootNode();\n if (rootNode instanceof ShadowRoot) {\n return rootNode;\n }\n return element.ownerDocument;\n })();\n\n // 2. Options to bias toward stable attrs and away from auto-generated classes\n const options = {\n root,\n // only use data-*, id or aria-label by default\n attr(name, value) {\n if (name === 'id' || name.startsWith('data-') || name === 'aria-label') {\n return true;\n }\n return false;\n },\n // skip framework junk\n filter(name, value) {\n if (name.startsWith('ng-') || name.startsWith('_ngcontent') || /^p-/.test(name)) {\n return false;\n }\n return true;\n },\n // let finder try really short seeds\n seedMinLength: 1,\n optimizedMinLength: 1,\n };\n\n let selector;\n try {\n selector = finder(element, options);\n // 3. Verify it really works in the context\n const found = root.querySelectorAll(selector);\n if (found.length !== 1 || found[0] !== element) {\n throw new Error('not unique or not found');\n }\n return selector;\n } catch (err) {\n // 4. Fallback: full path (you already have this utility)\n console.warn('[getRobustSelector] finder failed, falling back to full path:', err);\n return generateCssPath(element); // you’d import or define this elsewhere\n }\n }\n\n /**\n * Checks if an element is scrollable (has scrollable content)\n * \n * @param element - The element to check\n * @returns boolean indicating if the element is scrollable\n */\n function isScrollableContainer(element) {\n if (!element) return false;\n \n const style = window.getComputedStyle(element);\n \n // Reliable way to detect if an element has scrollbars or is scrollable\n const hasScrollHeight = element.scrollHeight > element.clientHeight;\n const hasScrollWidth = element.scrollWidth > element.clientWidth;\n \n // Check actual style properties\n const hasOverflowY = style.overflowY === 'auto' || \n style.overflowY === 'scroll' || \n style.overflowY === 'overlay';\n const hasOverflowX = style.overflowX === 'auto' || \n style.overflowX === 'scroll' || \n style.overflowX === 'overlay';\n \n // Check common class names and attributes for scrollable containers across frameworks\n const hasScrollClasses = element.classList.contains('scroll') || \n element.classList.contains('scrollable') ||\n element.classList.contains('overflow') ||\n element.classList.contains('overflow-auto') ||\n element.classList.contains('overflow-scroll') ||\n element.getAttribute('data-scrollable') === 'true';\n \n // Check for height/max-height constraints that often indicate scrolling content\n const hasHeightConstraint = style.maxHeight && \n style.maxHeight !== 'none' && \n style.maxHeight !== 'auto';\n \n // An element is scrollable if it has:\n // 1. Actual scrollbars in use (most reliable check) OR\n // 2. Overflow styles allowing scrolling AND content that would require scrolling\n return (hasScrollHeight && hasOverflowY) || \n (hasScrollWidth && hasOverflowX) ||\n (hasScrollClasses && (hasScrollHeight || hasScrollWidth)) ||\n (hasHeightConstraint && hasScrollHeight);\n }\n\n /**\n * Detects scrollable containers that are ancestors of the target element\n * \n * This function traverses up the DOM tree from the target element and identifies\n * all scrollable containers (elements that have scrollable content).\n * \n * @param target - The target element to start the search from\n * @returns Array of objects with selector and scroll properties\n */\n function detectScrollableContainers(target) {\n const scrollableContainers = [];\n \n if (!target) {\n return scrollableContainers;\n }\n \n console.log('🔍 [detectScrollableContainers] Starting detection for target:', target.tagName, target.id, target.className);\n \n // Detect if target is inside an iframe\n const iframe = getContainingIframe(target);\n const iframe_selector = iframe ? generateCssPath(iframe) : \"\";\n \n console.log('🔍 [detectScrollableContainers] Iframe context:', iframe ? 'inside iframe' : 'main document', 'selector:', iframe_selector);\n \n // Start from the target element and traverse up the DOM tree\n let currentElement = target;\n let depth = 0;\n const MAX_DEPTH = 10; // Limit traversal depth to avoid infinite loops\n \n while (currentElement && currentElement.nodeType === Node.ELEMENT_NODE && depth < MAX_DEPTH) { \n // Check if the current element is scrollable\n if (isScrollableContainer(currentElement)) {\n console.log('🔍 [detectScrollableContainers] Found scrollable container at depth', depth, ':', currentElement.tagName, currentElement.id, currentElement.className);\n \n const container = {\n containerEl: currentElement,\n selector: generateCssPath(currentElement),\n iframe_selector: iframe_selector,\n scrollTop: currentElement.scrollTop,\n scrollLeft: currentElement.scrollLeft,\n scrollHeight: currentElement.scrollHeight,\n scrollWidth: currentElement.scrollWidth,\n clientHeight: currentElement.clientHeight,\n clientWidth: currentElement.clientWidth\n };\n \n scrollableContainers.push(container);\n }\n \n // Move to parent element\n currentElement = getParentNode(currentElement);\n \n depth++;\n }\n \n console.log('🔍 [detectScrollableContainers] Detection complete. Found', scrollableContainers.length, 'scrollable containers');\n return scrollableContainers;\n }\n\n class DOMSerializer {\n constructor(options = {}) {\n this.options = {\n includeStyles: true,\n includeScripts: false, // Security consideration\n includeFrames: true,\n includeShadowDOM: true,\n maxDepth: 50,\n ...options\n };\n this.serializedFrames = new Map();\n this.shadowRoots = new Map();\n }\n \n /**\n * Serialize a complete document or element\n */\n serialize(rootElement = document) {\n try {\n const serialized = {\n type: 'document',\n doctype: this.serializeDoctype(rootElement),\n documentElement: this.serializeElement(rootElement.documentElement || rootElement),\n frames: [],\n timestamp: Date.now(),\n url: rootElement.URL || window.location?.href,\n metadata: {\n title: rootElement.title,\n charset: rootElement.characterSet,\n contentType: rootElement.contentType\n }\n };\n \n // Serialize frames and iframes if enabled\n if (this.options.includeFrames) {\n serialized.frames = this.serializeFrames(rootElement);\n }\n \n return serialized;\n } catch (error) {\n console.error('Serialization error:', error);\n throw new Error(`DOM serialization failed: ${error.message}`);\n }\n }\n \n /**\n * Serialize document type declaration\n */\n serializeDoctype(doc) {\n if (!doc.doctype) return null;\n \n return {\n name: doc.doctype.name,\n publicId: doc.doctype.publicId,\n systemId: doc.doctype.systemId\n };\n }\n \n /**\n * Serialize an individual element and its children\n */\n serializeElement(element, depth = 0) {\n if (depth > this.options.maxDepth) {\n return { type: 'text', content: '<!-- Max depth exceeded -->' };\n }\n \n const nodeType = element.nodeType;\n \n switch (nodeType) {\n case Node.ELEMENT_NODE:\n return this.serializeElementNode(element, depth);\n case Node.TEXT_NODE:\n return this.serializeTextNode(element);\n case Node.COMMENT_NODE:\n return this.serializeCommentNode(element);\n case Node.DOCUMENT_FRAGMENT_NODE:\n return this.serializeDocumentFragment(element, depth);\n default:\n return null;\n }\n }\n \n /**\n * Serialize element node with attributes and children\n */\n serializeElementNode(element, depth) {\n const tagName = element.tagName.toLowerCase();\n \n // Skip script tags for security unless explicitly enabled\n if (tagName === 'script' && !this.options.includeScripts) {\n return { type: 'comment', content: '<!-- Script tag removed for security -->' };\n }\n \n const serialized = {\n type: 'element',\n tagName: tagName,\n attributes: this.serializeAttributes(element),\n children: [],\n shadowRoot: null\n };\n \n // Handle Shadow DOM\n if (this.options.includeShadowDOM && element.shadowRoot) {\n serialized.shadowRoot = this.serializeShadowRoot(element.shadowRoot, depth + 1);\n }\n \n // Handle special elements\n if (tagName === 'iframe' || tagName === 'frame') {\n serialized.frameData = this.serializeFrameElement(element);\n }\n \n // Serialize children\n for (const child of element.childNodes) {\n const serializedChild = this.serializeElement(child, depth + 1);\n if (serializedChild) {\n serialized.children.push(serializedChild);\n }\n }\n \n // Include computed styles if enabled\n if (this.options.includeStyles && element.nodeType === Node.ELEMENT_NODE) {\n serialized.computedStyle = this.serializeComputedStyle(element);\n }\n \n return serialized;\n }\n \n /**\n * Serialize element attributes\n */\n serializeAttributes(element) {\n const attributes = {};\n \n if (element.attributes) {\n for (const attr of element.attributes) {\n attributes[attr.name] = attr.value;\n }\n }\n \n return attributes;\n }\n \n /**\n * Serialize computed styles\n */\n serializeComputedStyle(element) {\n try {\n const computedStyle = window.getComputedStyle(element);\n const styles = {};\n \n // Only serialize non-default values to reduce size\n const importantStyles = [\n 'display', 'position', 'width', 'height', 'margin', 'padding',\n 'border', 'background', 'color', 'font-family', 'font-size',\n 'text-align', 'visibility', 'z-index', 'transform'\n ];\n \n for (const prop of importantStyles) {\n const value = computedStyle.getPropertyValue(prop);\n if (value && value !== 'initial' && value !== 'normal') {\n styles[prop] = value;\n }\n }\n \n return styles;\n } catch (error) {\n return {};\n }\n }\n \n /**\n * Serialize text node\n */\n serializeTextNode(node) {\n return {\n type: 'text',\n content: node.textContent\n };\n }\n \n /**\n * Serialize comment node\n */\n serializeCommentNode(node) {\n return {\n type: 'comment',\n content: node.textContent\n };\n }\n \n /**\n * Serialize document fragment\n */\n serializeDocumentFragment(fragment, depth) {\n const serialized = {\n type: 'fragment',\n children: []\n };\n \n for (const child of fragment.childNodes) {\n const serializedChild = this.serializeElement(child, depth + 1);\n if (serializedChild) {\n serialized.children.push(serializedChild);\n }\n }\n \n return serialized;\n }\n \n /**\n * Serialize Shadow DOM\n */\n serializeShadowRoot(shadowRoot, depth) {\n const serialized = {\n type: 'shadowRoot',\n mode: shadowRoot.mode,\n children: []\n };\n \n for (const child of shadowRoot.childNodes) {\n const serializedChild = this.serializeElement(child, depth + 1);\n if (serializedChild) {\n serialized.children.push(serializedChild);\n }\n }\n \n return serialized;\n }\n \n /**\n * Serialize frame/iframe elements\n */\n serializeFrameElement(frameElement) {\n const frameData = {\n src: frameElement.src,\n name: frameElement.name,\n id: frameElement.id,\n sandbox: frameElement.sandbox?.toString() || '',\n allowfullscreen: frameElement.allowFullscreen\n };\n \n // Try to access frame content (may fail due to CORS)\n try {\n const frameDoc = frameElement.contentDocument;\n if (frameDoc && this.options.includeFrames) {\n frameData.content = this.serialize(frameDoc);\n }\n } catch (error) {\n frameData.accessError = 'Cross-origin frame content not accessible';\n }\n \n return frameData;\n }\n \n /**\n * Serialize all frames in document\n */\n serializeFrames(doc) {\n const frames = [];\n const frameElements = doc.querySelectorAll('iframe, frame');\n \n for (const frameElement of frameElements) {\n try {\n const frameDoc = frameElement.contentDocument;\n if (frameDoc) {\n frames.push({\n element: this.serializeElement(frameElement),\n content: this.serialize(frameDoc)\n });\n }\n } catch (error) {\n frames.push({\n element: this.serializeElement(frameElement),\n error: 'Frame content not accessible'\n });\n }\n }\n \n return frames;\n }\n \n /**\n * Deserialize serialized DOM data back to DOM nodes\n */\n deserialize(serializedData, targetDocument = document) {\n try {\n if (serializedData.type === 'document') {\n return this.deserializeDocument(serializedData, targetDocument);\n } else {\n return this.deserializeElement(serializedData, targetDocument);\n }\n } catch (error) {\n console.error('Deserialization error:', error);\n throw new Error(`DOM deserialization failed: ${error.message}`);\n }\n }\n \n /**\n * Deserialize complete document\n */\n deserializeDocument(serializedDoc, targetDoc) {\n // Create new document if needed\n const doc = targetDoc || document.implementation.createHTMLDocument();\n \n // Set doctype if present\n if (serializedDoc.doctype) {\n const doctype = document.implementation.createDocumentType(\n serializedDoc.doctype.name,\n serializedDoc.doctype.publicId,\n serializedDoc.doctype.systemId\n );\n doc.replaceChild(doctype, doc.doctype);\n }\n \n // Deserialize document element\n if (serializedDoc.documentElement) {\n const newDocElement = this.deserializeElement(serializedDoc.documentElement, doc);\n doc.replaceChild(newDocElement, doc.documentElement);\n }\n \n // Handle metadata\n if (serializedDoc.metadata) {\n doc.title = serializedDoc.metadata.title || '';\n }\n \n return doc;\n }\n \n /**\n * Deserialize individual element\n */\n deserializeElement(serializedNode, doc) {\n switch (serializedNode.type) {\n case 'element':\n return this.deserializeElementNode(serializedNode, doc);\n case 'text':\n return doc.createTextNode(serializedNode.content);\n case 'comment':\n return doc.createComment(serializedNode.content);\n case 'fragment':\n return this.deserializeDocumentFragment(serializedNode, doc);\n case 'shadowRoot':\n // Shadow roots are handled during element creation\n return null;\n default:\n return null;\n }\n }\n \n /**\n * Deserialize element node\n */\n deserializeElementNode(serializedElement, doc) {\n const element = doc.createElement(serializedElement.tagName);\n \n // Set attributes\n if (serializedElement.attributes) {\n for (const [name, value] of Object.entries(serializedElement.attributes)) {\n try {\n element.setAttribute(name, value);\n } catch (error) {\n console.warn(`Failed to set attribute ${name}:`, error);\n }\n }\n }\n \n // Apply computed styles if available\n if (serializedElement.computedStyle && this.options.includeStyles) {\n for (const [prop, value] of Object.entries(serializedElement.computedStyle)) {\n try {\n element.style.setProperty(prop, value);\n } catch (error) {\n console.warn(`Failed to set style ${prop}:`, error);\n }\n }\n }\n \n // Create shadow root if present\n if (serializedElement.shadowRoot && element.attachShadow) {\n try {\n const shadowRoot = element.attachShadow({ \n mode: serializedElement.shadowRoot.mode || 'open' \n });\n \n // Deserialize shadow root children\n for (const child of serializedElement.shadowRoot.children) {\n const childElement = this.deserializeElement(child, doc);\n if (childElement) {\n shadowRoot.appendChild(childElement);\n }\n }\n } catch (error) {\n console.warn('Failed to create shadow root:', error);\n }\n }\n \n // Deserialize children\n if (serializedElement.children) {\n for (const child of serializedElement.children) {\n const childElement = this.deserializeElement(child, doc);\n if (childElement) {\n element.appendChild(childElement);\n }\n }\n }\n \n // Handle frame content\n if (serializedElement.frameData && serializedElement.frameData.content) {\n // Frame content deserialization would happen after the frame loads\n element.addEventListener('load', () => {\n try {\n const frameDoc = element.contentDocument;\n if (frameDoc) {\n this.deserializeDocument(serializedElement.frameData.content, frameDoc);\n }\n } catch (error) {\n console.warn('Failed to deserialize frame content:', error);\n }\n });\n }\n \n return element;\n }\n \n /**\n * Deserialize document fragment\n */\n deserializeDocumentFragment(serializedFragment, doc) {\n const fragment = doc.createDocumentFragment();\n \n if (serializedFragment.children) {\n for (const child of serializedFragment.children) {\n const childElement = this.deserializeElement(child, doc);\n if (childElement) {\n fragment.appendChild(childElement);\n }\n }\n }\n \n return fragment;\n }\n }\n \n // Usage example and utility functions\n class DOMUtils {\n /**\n * Create serializer with common presets\n */\n static createSerializer(preset = 'default') {\n const presets = {\n default: {\n includeStyles: true,\n includeScripts: false,\n includeFrames: true,\n includeShadowDOM: true\n },\n minimal: {\n includeStyles: false,\n includeScripts: false,\n includeFrames: false,\n includeShadowDOM: false\n },\n complete: {\n includeStyles: true,\n includeScripts: true,\n includeFrames: true,\n includeShadowDOM: true\n },\n secure: {\n includeStyles: true,\n includeScripts: false,\n includeFrames: false,\n includeShadowDOM: true\n }\n };\n \n return new DOMSerializer(presets[preset] || presets.default);\n }\n \n /**\n * Serialize DOM to JSON string\n */\n static serializeToJSON(element, options) {\n const serializer = new DOMSerializer(options);\n const serialized = serializer.serialize(element);\n return JSON.stringify(serialized, null, 2);\n }\n \n /**\n * Deserialize from JSON string\n */\n static deserializeFromJSON(jsonString, targetDocument) {\n const serialized = JSON.parse(jsonString);\n const serializer = new DOMSerializer();\n return serializer.deserialize(serialized, targetDocument);\n }\n \n /**\n * Clone DOM with full fidelity including Shadow DOM\n */\n static deepClone(element, options) {\n const serializer = new DOMSerializer(options);\n const serialized = serializer.serialize(element);\n return serializer.deserialize(serialized, element.ownerDocument);\n }\n \n /**\n * Compare two DOM structures\n */\n static compare(element1, element2, options) {\n const serializer = new DOMSerializer(options);\n const serialized1 = serializer.serialize(element1);\n const serialized2 = serializer.serialize(element2);\n \n return JSON.stringify(serialized1) === JSON.stringify(serialized2);\n }\n }\n \n /*\n // Export for use\n if (typeof module !== 'undefined' && module.exports) {\n module.exports = { DOMSerializer, DOMUtils };\n } else if (typeof window !== 'undefined') {\n window.DOMSerializer = DOMSerializer;\n window.DOMUtils = DOMUtils;\n }\n */\n\n /* Usage Examples:\n \n // Basic serialization\n const serializer = new DOMSerializer();\n const serialized = serializer.serialize(document);\n console.log(JSON.stringify(serialized, null, 2));\n \n // Deserialize back to DOM\n const clonedDoc = serializer.deserialize(serialized);\n \n // Using presets\n const minimalSerializer = DOMUtils.createSerializer('minimal');\n const secureSerializer = DOMUtils.createSerializer('secure');\n \n // Serialize specific element with Shadow DOM\n const customElement = document.querySelector('my-custom-element');\n const serializedElement = serializer.serialize(customElement);\n \n // JSON utilities\n const jsonString = DOMUtils.serializeToJSON(document.body);\n const restored = DOMUtils.deserializeFromJSON(jsonString);\n \n // Deep clone with Shadow DOM support\n const clone = DOMUtils.deepClone(document.body, { includeShadowDOM: true });\n \n */\n\n function serializeNodeToJSON(nodeElement) {\n return DOMUtils.serializeToJSON(nodeElement, {includeStyles: false});\n }\n\n function deserializeNodeFromJSON(jsonString) {\n return DOMUtils.deserializeFromJSON(jsonString);\n }\n\n /**\n * Checks if a point is inside a bounding box\n * \n * @param point The point to check\n * @param box The bounding box\n * @returns boolean indicating if the point is inside the box\n */\n function isPointInsideBox(point, box) {\n return point.x >= box.x &&\n point.x <= box.x + box.width &&\n point.y >= box.y &&\n point.y <= box.y + box.height;\n }\n\n /**\n * Calculates the overlap area between two bounding boxes\n * \n * @param box1 First bounding box\n * @param box2 Second bounding box\n * @returns The overlap area\n */\n function calculateOverlap(box1, box2) {\n const xOverlap = Math.max(0,\n Math.min(box1.x + box1.width, box2.x + box2.width) -\n Math.max(box1.x, box2.x)\n );\n const yOverlap = Math.max(0,\n Math.min(box1.y + box1.height, box2.y + box2.height) -\n Math.max(box1.y, box2.y)\n );\n return xOverlap * yOverlap;\n }\n\n /**\n * Finds an exact match between candidate elements and the actual interaction element\n * \n * @param candidate_elements Array of candidate element infos\n * @param actualInteractionElementInfo The actual interaction element info\n * @returns The matching candidate element info, or null if no match is found\n */\n function findExactMatch(candidate_elements, actualInteractionElementInfo) {\n if (!actualInteractionElementInfo.element) {\n return null;\n }\n\n const exactMatch = candidate_elements.find(elementInfo => \n elementInfo.element && elementInfo.element === actualInteractionElementInfo.element\n );\n \n if (exactMatch) {\n console.log('✅ Found exact element match:', {\n matchedElement: exactMatch.element?.tagName,\n matchedElementClass: exactMatch.element?.className,\n index: exactMatch.index\n });\n return exactMatch;\n }\n \n return null;\n }\n\n /**\n * Finds a match by traversing up the parent elements\n * \n * @param candidate_elements Array of candidate element infos\n * @param actualInteractionElementInfo The actual interaction element info\n * @returns The matching candidate element info, or null if no match is found\n */\n function findParentMatch(candidate_elements, actualInteractionElementInfo) {\n if (!actualInteractionElementInfo.element) {\n return null;\n }\n\n let element = actualInteractionElementInfo.element;\n while (element.parentElement) {\n element = element.parentElement;\n const parentMatch = candidate_elements.find(candidate => \n candidate.element && candidate.element === element\n );\n \n if (parentMatch) {\n console.log('✅ Found parent element match:', {\n matchedElement: parentMatch.element?.tagName,\n matchedElementClass: parentMatch.element?.className,\n index: parentMatch.index,\n depth: element.tagName\n });\n return parentMatch;\n }\n \n // Stop if we hit another candidate element\n if (candidate_elements.some(candidate => \n candidate.element && candidate.element === element\n )) {\n console.log('⚠️ Stopped parent search - hit another candidate element:', element.tagName);\n break;\n }\n }\n \n return null;\n }\n\n /**\n * Finds a match based on spatial relationships between elements\n * \n * @param candidate_elements Array of candidate element infos\n * @param actualInteractionElementInfo The actual interaction element info\n * @returns The matching candidate element info, or null if no match is found\n */\n function findSpatialMatch(candidate_elements, actualInteractionElementInfo) {\n if (!actualInteractionElementInfo.element || !actualInteractionElementInfo.bounding_box) {\n return null;\n }\n\n const actualBox = actualInteractionElementInfo.bounding_box;\n let bestMatch = null;\n let bestScore = 0;\n\n for (const candidateInfo of candidate_elements) {\n if (!candidateInfo.bounding_box) continue;\n \n const candidateBox = candidateInfo.bounding_box;\n let score = 0;\n\n // Check if actual element is contained within candidate\n if (isPointInsideBox({ x: actualBox.x, y: actualBox.y }, candidateBox) &&\n isPointInsideBox({ x: actualBox.x + actualBox.width, y: actualBox.y + actualBox.height }, candidateBox)) {\n score += 100; // High score for containment\n }\n\n // Calculate overlap area as a factor\n const overlap = calculateOverlap(actualBox, candidateBox);\n score += overlap;\n\n // Consider proximity if no containment\n if (score === 0) {\n const distance = Math.sqrt(\n Math.pow((actualBox.x + actualBox.width/2) - (candidateBox.x + candidateBox.width/2), 2) +\n Math.pow((actualBox.y + actualBox.height/2) - (candidateBox.y + candidateBox.height/2), 2)\n );\n // Convert distance to a score (closer = higher score)\n score = 1000 / (distance + 1);\n }\n\n if (score > bestScore) {\n bestScore = score;\n bestMatch = candidateInfo;\n console.log('📏 New best spatial match:', {\n element: candidateInfo.element?.tagName,\n class: candidateInfo.element?.className,\n index: candidateInfo.index,\n score: score\n });\n }\n }\n\n if (bestMatch) {\n console.log('✅ Final spatial match selected:', {\n element: bestMatch.element?.tagName,\n class: bestMatch.element?.className,\n index: bestMatch.index,\n finalScore: bestScore\n });\n return bestMatch;\n }\n\n return null;\n }\n\n /**\n * Finds a matching candidate element for an actual interaction element\n * \n * @param candidate_elements Array of candidate element infos\n * @param actualInteractionElementInfo The actual interaction element info\n * @returns The matching candidate element info, or null if no match is found\n */\n function findMatchingCandidateElementInfo(candidate_elements, actualInteractionElementInfo) {\n if (!actualInteractionElementInfo.element || !actualInteractionElementInfo.bounding_box) {\n console.error('❌ Missing required properties in actualInteractionElementInfo');\n return null;\n }\n\n console.log('🔍 Starting element matching for:', {\n clickedElement: actualInteractionElementInfo.element.tagName,\n clickedElementClass: actualInteractionElementInfo.element.className,\n totalCandidates: candidate_elements.length\n });\n\n // First try exact element match\n const exactMatch = findExactMatch(candidate_elements, actualInteractionElementInfo);\n if (exactMatch) {\n return exactMatch;\n }\n console.log('❌ No exact element match found, trying parent matching...');\n\n // Try finding closest clickable parent\n const parentMatch = findParentMatch(candidate_elements, actualInteractionElementInfo);\n if (parentMatch) {\n return parentMatch;\n }\n console.log('❌ No parent match found, falling back to spatial matching...');\n\n // If no exact or parent match, look for spatial relationships\n const spatialMatch = findSpatialMatch(candidate_elements, actualInteractionElementInfo);\n if (spatialMatch) {\n return spatialMatch;\n }\n\n console.error('❌ No matching element found for actual interaction element:', actualInteractionElementInfo);\n return null;\n }\n\n const highlight = {\n execute: async function(elementTypes, handleScroll=false) {\n const elements = await findElements(elementTypes);\n highlightElements(elements, handleScroll);\n return elements;\n },\n\n unexecute: function(handleScroll=false) {\n unhighlightElements(handleScroll);\n },\n\n generateJSON: async function() {\n const json = {};\n\n // Capture viewport dimensions\n const viewportData = {\n width: window.innerWidth,\n height: window.innerHeight,\n documentWidth: document.documentElement.clientWidth,\n documentHeight: document.documentElement.clientHeight,\n timestamp: new Date().toISOString()\n };\n\n // Add viewport data to the JSON output\n json.viewport = viewportData;\n\n\n await Promise.all(Object.values(ElementTag).map(async elementType => {\n const elements = await findElements(elementType);\n json[elementType] = elements;\n }));\n\n // Serialize the JSON object\n const jsonString = JSON.stringify(json, null, 4); // Pretty print with 4 spaces\n\n console.log(`JSON: ${jsonString}`);\n return jsonString;\n },\n\n getElementInfo\n };\n\n\n function unhighlightElements(handleScroll=false) {\n const documents = getAllFrames();\n documents.forEach(doc => {\n const overlay = doc.getElementById('highlight-overlay');\n if (overlay) {\n if (handleScroll) {\n // Remove event listeners\n doc.removeEventListener('scroll', overlay.scrollHandler, true);\n doc.removeEventListener('resize', overlay.resizeHandler);\n }\n overlay.remove();\n }\n });\n }\n\n\n\n\n async function findElements(elementTypes, verbose=true) {\n const typesArray = Array.isArray(elementTypes) ? elementTypes : [elementTypes];\n console.log('Starting element search for types:', typesArray);\n\n const elements = [];\n typesArray.forEach(elementType => {\n if (elementType === ElementTag.FILLABLE) {\n elements.push(...findFillables());\n }\n if (elementType === ElementTag.SELECTABLE) {\n elements.push(...findDropdowns());\n }\n if (elementType === ElementTag.CLICKABLE) {\n elements.push(...findClickables());\n elements.push(...findToggles());\n elements.push(...findCheckables());\n }\n if (elementType === ElementTag.NON_INTERACTIVE_ELEMENT) {\n elements.push(...findNonInteractiveElements());\n }\n });\n\n // console.log('Before uniquify:', elements.length);\n const elementsWithInfo = elements.map((element, index) => \n getElementInfo(element, index)\n );\n\n \n \n const uniqueElements = uniquifyElements(elementsWithInfo);\n console.log(`Found ${uniqueElements.length} elements:`);\n \n // More comprehensive visibility check\n const visibleElements = uniqueElements.filter(elementInfo => {\n const el = elementInfo.element;\n const style = getComputedStyle(el);\n \n // Check various style properties that affect visibility\n if (style.display === 'none' || \n style.visibility === 'hidden') {\n return false;\n }\n \n // Check if element has non-zero dimensions\n const rect = el.getBoundingClientRect();\n if (rect.width === 0 || rect.height === 0) {\n return false;\n }\n \n // Check if element is within viewport\n if (rect.bottom < 0 || \n rect.top > window.innerHeight || \n rect.right < 0 || \n rect.left > window.innerWidth) {\n // Element is outside viewport, but still might be valid \n // if user scrolls to it, so we'll include it\n return true;\n }\n \n return true;\n });\n \n console.log(`Out of which ${visibleElements.length} elements are visible:`);\n if (verbose) {\n visibleElements.forEach(info => {\n console.log(`Element ${info.index}:`, info);\n });\n }\n \n return visibleElements;\n }\n\n // elements is an array of objects with index, xpath\n function highlightElements(elements, handleScroll=false) {\n // console.log('[highlightElements] called with', elements.length, 'elements');\n // Create overlay if it doesn't exist and store it in a dictionary\n const documents = getAllFrames(); \n let overlays = {};\n documents.forEach(doc => {\n let overlay = doc.getElementById('highlight-overlay');\n if (!overlay) {\n overlay = doc.createElement('div');\n overlay.id = 'highlight-overlay';\n overlay.style.cssText = `\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n pointer-events: none;\n z-index: 2147483647;\n `;\n doc.body.appendChild(overlay);\n // console.log('[highlightElements] Created overlay in document:', doc);\n }\n overlays[doc.documentURI] = overlay;\n });\n \n\n const updateHighlights = (doc = null) => {\n if (doc) {\n overlays[doc.documentURI].innerHTML = '';\n } else {\n Object.values(overlays).forEach(overlay => { overlay.innerHTML = ''; });\n } \n elements.forEach((elementInfo, idx) => {\n //console.log(`[highlightElements] Processing element ${idx}:`, elementInfo.tag, elementInfo.css_selector, elementInfo.bounding_box);\n let element = elementInfo.element; //getElementByXPathOrCssSelector(elementInfo);\n if (!element) {\n element = getElementByXPathOrCssSelector(elementInfo);\n if (!element) {\n console.warn('[highlightElements] Could not find element for:', elementInfo);\n return;\n }\n }\n //if highlights requested for a specific doc, skip unrelated elements\n if (doc && element.ownerDocument !== doc) {\n console.log(\"[highlightElements] Skipped element since it doesn't belong to document\", doc);\n return;\n }\n const rect = element.getBoundingClientRect();\n if (rect.width === 0 || rect.height === 0) {\n console.warn('[highlightElements] Element has zero dimensions:', elementInfo);\n return;\n }\n // Create border highlight (red rectangle)\n // use ownerDocument to support iframes/frames\n const highlight = element.ownerDocument.createElement('div');\n highlight.style.cssText = `\n position: fixed;\n left: ${rect.x}px;\n top: ${rect.y}px;\n width: ${rect.width}px;\n height: ${rect.height}px;\n border: 1px solid rgb(255, 0, 0);\n transition: all 0.2s ease-in-out;\n `;\n // Create index label container - now positioned to the right and slightly up\n const labelContainer = element.ownerDocument.createElement('div');\n labelContainer.style.cssText = `\n position: absolute;\n right: -10px; /* Offset to the right */\n top: -10px; /* Offset upwards */\n padding: 4px;\n background-color: rgba(255, 255, 0, 0.6);\n display: flex;\n align-items: center;\n justify-content: center;\n `;\n const text = element.ownerDocument.createElement('span');\n text.style.cssText = `\n color: rgb(0, 0, 0, 0.8);\n font-family: 'Courier New', Courier, monospace;\n font-size: 12px;\n font-weight: bold;\n line-height: 1;\n `;\n text.textContent = elementInfo.index;\n labelContainer.appendChild(text);\n highlight.appendChild(labelContainer); \n overlays[element.ownerDocument.documentURI].appendChild(highlight);\n \n });\n };\n\n // Initial highlight\n updateHighlights();\n\n if (handleScroll) {\n documents.forEach(doc => {\n // Update highlights on scroll and resize\n console.log('registering scroll and resize handlers for document: ', doc);\n const scrollHandler = () => {\n requestAnimationFrame(() => updateHighlights(doc));\n };\n const resizeHandler = () => {\n updateHighlights(doc);\n };\n doc.addEventListener('scroll', scrollHandler, true);\n doc.addEventListener('resize', resizeHandler);\n // Store event handlers for cleanup\n overlays[doc.documentURI].scrollHandler = scrollHandler;\n overlays[doc.documentURI].resizeHandler = resizeHandler;\n }); \n }\n }\n\n // function unexecute() {\n // unhighlightElements();\n // }\n\n // Make it available globally for both Extension and Playwright\n if (typeof window !== 'undefined') {\n function stripElementRefs(elementInfo) {\n if (!elementInfo) return null;\n const { element, ...rest } = elementInfo;\n return rest;\n }\n\n window.ProboLabs = window.ProboLabs || {};\n\n // --- Caching State ---\n window.ProboLabs.candidates = [];\n window.ProboLabs.actual = null;\n window.ProboLabs.matchingCandidate = null;\n\n // --- Methods ---\n /**\n * Find and cache candidate elements of a given type (e.g., 'CLICKABLE').\n * NOTE: This function is async and must be awaited from Playwright/Node.\n */\n window.ProboLabs.findAndCacheCandidateElements = async function(elementType) {\n //console.log('[ProboLabs] findAndCacheCandidateElements called with:', elementType);\n const found = await findElements(elementType);\n window.ProboLabs.candidates = found;\n // console.log('[ProboLabs] candidates set to:', found, 'type:', typeof found, 'isArray:', Array.isArray(found));\n return found.length;\n };\n\n window.ProboLabs.findAndCacheActualElement = function(cssSelector, iframeSelector, isHover=false) {\n // console.log('[ProboLabs] findAndCacheActualElement called with:', cssSelector, iframeSelector);\n let el = findElement(document, iframeSelector, cssSelector);\n if(isHover) {\n const visibleElement = findClosestVisibleElement(el);\n if (visibleElement) {\n el = visibleElement;\n }\n }\n if (!el) {\n window.ProboLabs.actual = null;\n // console.log('[ProboLabs] actual set to null');\n return false;\n }\n window.ProboLabs.actual = getElementInfo(el, -1);\n // console.log('[ProboLabs] actual set to:', window.ProboLabs.actual);\n return true;\n };\n\n window.ProboLabs.findAndCacheMatchingCandidate = function() {\n // console.log('[ProboLabs] findAndCacheMatchingCandidate called');\n if (!window.ProboLabs.candidates.length || !window.ProboLabs.actual) {\n window.ProboLabs.matchingCandidate = null;\n // console.log('[ProboLabs] matchingCandidate set to null');\n return false;\n }\n window.ProboLabs.matchingCandidate = findMatchingCandidateElementInfo(window.ProboLabs.candidates, window.ProboLabs.actual);\n // console.log('[ProboLabs] matchingCandidate set to:', window.ProboLabs.matchingCandidate);\n return !!window.ProboLabs.matchingCandidate;\n };\n\n window.ProboLabs.highlightCachedElements = function(which) {\n let elements = [];\n if (which === 'candidates') elements = window.ProboLabs.candidates;\n if (which === 'actual' && window.ProboLabs.actual) elements = [window.ProboLabs.actual];\n if (which === 'matching' && window.ProboLabs.matchingCandidate) elements = [window.ProboLabs.matchingCandidate];\n console.log(`[ProboLabs] highlightCachedElements ${which} with ${elements.length} elements`);\n highlightElements(elements);\n };\n\n window.ProboLabs.unhighlight = function() {\n // console.log('[ProboLabs] unhighlight called');\n unhighlightElements();\n };\n\n window.ProboLabs.reset = function() {\n console.log('[ProboLabs] reset called');\n window.ProboLabs.candidates = [];\n window.ProboLabs.actual = null;\n window.ProboLabs.matchingCandidate = null;\n unhighlightElements();\n };\n\n window.ProboLabs.getCandidates = function() {\n // console.log('[ProboLabs] getCandidates called. candidates:', window.ProboLabs.candidates, 'type:', typeof window.ProboLabs.candidates, 'isArray:', Array.isArray(window.ProboLabs.candidates));\n const arr = Array.isArray(window.ProboLabs.candidates) ? window.ProboLabs.candidates : [];\n return arr.map(stripElementRefs);\n };\n window.ProboLabs.getActual = function() {\n return stripElementRefs(window.ProboLabs.actual);\n };\n window.ProboLabs.getMatchingCandidate = function() {\n return stripElementRefs(window.ProboLabs.matchingCandidate);\n };\n\n // Retain existing API for backward compatibility\n window.ProboLabs.ElementTag = ElementTag;\n window.ProboLabs.highlightElements = highlightElements;\n window.ProboLabs.unhighlightElements = unhighlightElements;\n window.ProboLabs.findElements = findElements;\n window.ProboLabs.getElementInfo = getElementInfo;\n window.ProboLabs.highlight = window.ProboLabs.highlight;\n window.ProboLabs.unhighlight = window.ProboLabs.unhighlight;\n\n // --- Utility Functions ---\n function findClosestVisibleElement(element) {\n let current = element;\n while (current) {\n const style = window.getComputedStyle(current);\n if (\n style &&\n style.display !== 'none' &&\n style.visibility !== 'hidden' &&\n current.offsetWidth > 0 &&\n current.offsetHeight > 0\n ) {\n return current;\n }\n if (!current.parentElement || current === document.body) break;\n current = current.parentElement;\n }\n return null;\n }\n }\n\n exports.ElementInfo = ElementInfo;\n exports.ElementTag = ElementTag;\n exports.deserializeNodeFromJSON = deserializeNodeFromJSON;\n exports.detectScrollableContainers = detectScrollableContainers;\n exports.findElement = findElement;\n exports.findElements = findElements;\n exports.generateCssPath = generateCssPath;\n exports.getAriaLabelledByText = getAriaLabelledByText;\n exports.getContainingIframe = getContainingIframe;\n exports.getElementInfo = getElementInfo;\n exports.getParentNode = getParentNode;\n exports.getRobustSelector = getRobustSelector;\n exports.highlight = highlight;\n exports.highlightElements = highlightElements;\n exports.isScrollableContainer = isScrollableContainer;\n exports.serializeNodeToJSON = serializeNodeToJSON;\n exports.unhighlightElements = unhighlightElements;\n\n}));\n//# sourceMappingURL=probolabs.umd.js.map\n";
|
|
2
2
|
var ApplyAIStatus;
|
|
3
3
|
(function (ApplyAIStatus) {
|
|
4
4
|
ApplyAIStatus["PREPARE_START"] = "PREPARE_START";
|
|
@@ -32,6 +32,7 @@ var PlaywrightAction;
|
|
|
32
32
|
PlaywrightAction["CHECK_CHECKBOX"] = "CHECK_CHECKBOX";
|
|
33
33
|
PlaywrightAction["SELECT_RADIO"] = "SELECT_RADIO";
|
|
34
34
|
PlaywrightAction["TOGGLE_SWITCH"] = "TOGGLE_SWITCH";
|
|
35
|
+
PlaywrightAction["SET_SLIDER"] = "SET_SLIDER";
|
|
35
36
|
PlaywrightAction["TYPE_KEYS"] = "TYPE_KEYS";
|
|
36
37
|
PlaywrightAction["HOVER"] = "HOVER";
|
|
37
38
|
PlaywrightAction["ASSERT_EXACT_VALUE"] = "ASSERT_EXACT_VALUE";
|
|
@@ -43,6 +44,7 @@ var PlaywrightAction;
|
|
|
43
44
|
PlaywrightAction["EXECUTE_SCRIPT"] = "EXECUTE_SCRIPT";
|
|
44
45
|
PlaywrightAction["UPLOAD_FILES"] = "UPLOAD_FILES";
|
|
45
46
|
PlaywrightAction["WAIT_FOR"] = "WAIT_FOR";
|
|
47
|
+
PlaywrightAction["WAIT_FOR_OTP"] = "WAIT_FOR_OTP";
|
|
46
48
|
})(PlaywrightAction || (PlaywrightAction = {}));
|
|
47
49
|
|
|
48
50
|
// WebSocketsMessageType enum for WebSocket and event message types shared across the app
|
|
@@ -63,6 +65,7 @@ var WebSocketsMessageType;
|
|
|
63
65
|
WebSocketsMessageType["INTERACTION_STEP_CREATED"] = "INTERACTION_STEP_CREATED";
|
|
64
66
|
WebSocketsMessageType["INTERACTION_APPLY_AI_SUMMARY_COMPLETED"] = "INTERACTION_APPLY_AI_SUMMARY_COMPLETED";
|
|
65
67
|
WebSocketsMessageType["INTERACTION_APPLY_AI_SUMMARY_ERROR"] = "INTERACTION_APPLY_AI_SUMMARY_ERROR";
|
|
68
|
+
WebSocketsMessageType["OTP_RETRIEVED"] = "OTP_RETRIEVED";
|
|
66
69
|
})(WebSocketsMessageType || (WebSocketsMessageType = {}));
|
|
67
70
|
|
|
68
71
|
/**
|
|
@@ -554,10 +557,11 @@ const errorMessages = new Set([
|
|
|
554
557
|
'Failed to fetch', // Chrome
|
|
555
558
|
'NetworkError when attempting to fetch resource.', // Firefox
|
|
556
559
|
'The Internet connection appears to be offline.', // Safari 16
|
|
557
|
-
'Load failed', // Safari 17+
|
|
558
560
|
'Network request failed', // `cross-fetch`
|
|
559
561
|
'fetch failed', // Undici (Node.js)
|
|
560
562
|
'terminated', // Undici (Node.js)
|
|
563
|
+
' A network error occurred.', // Bun (WebKit)
|
|
564
|
+
'Network connection lost', // Cloudflare Workers (fetch)
|
|
561
565
|
]);
|
|
562
566
|
|
|
563
567
|
function isNetworkError(error) {
|
|
@@ -570,13 +574,22 @@ function isNetworkError(error) {
|
|
|
570
574
|
return false;
|
|
571
575
|
}
|
|
572
576
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
+
const {message, stack} = error;
|
|
578
|
+
|
|
579
|
+
// Safari 17+ has generic message but no stack for network errors
|
|
580
|
+
if (message === 'Load failed') {
|
|
581
|
+
return stack === undefined
|
|
582
|
+
// Sentry adds its own stack trace to the fetch error, so also check for that
|
|
583
|
+
|| '__sentry_captured__' in error;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Deno network errors start with specific text
|
|
587
|
+
if (message.startsWith('error sending request for url')) {
|
|
588
|
+
return true;
|
|
577
589
|
}
|
|
578
590
|
|
|
579
|
-
|
|
591
|
+
// Standard network error messages
|
|
592
|
+
return errorMessages.has(message);
|
|
580
593
|
}
|
|
581
594
|
|
|
582
595
|
class AbortError extends Error {
|
|
@@ -605,7 +618,7 @@ const decorateErrorWithCounts = (error, attemptNumber, options) => {
|
|
|
605
618
|
return error;
|
|
606
619
|
};
|
|
607
620
|
|
|
608
|
-
async function pRetry
|
|
621
|
+
async function pRetry(input, options) {
|
|
609
622
|
return new Promise((resolve, reject) => {
|
|
610
623
|
options = {...options};
|
|
611
624
|
options.onFailedAttempt ??= () => {};
|
|
@@ -732,7 +745,7 @@ class ApiClient {
|
|
|
732
745
|
}
|
|
733
746
|
async createStep(options) {
|
|
734
747
|
apiLogger.debug('creating step ', options.stepPrompt);
|
|
735
|
-
return pRetry
|
|
748
|
+
return pRetry(async () => {
|
|
736
749
|
const response = await fetch(`${this.apiUrl}/step-runners/`, {
|
|
737
750
|
method: 'POST',
|
|
738
751
|
headers: this.getHeaders(),
|
|
@@ -759,7 +772,7 @@ class ApiClient {
|
|
|
759
772
|
async patchStep(stepId, fields) {
|
|
760
773
|
// Use PATCH /steps/:id/ endpoint
|
|
761
774
|
apiLogger.debug(`patching step #${stepId}`);
|
|
762
|
-
return pRetry
|
|
775
|
+
return pRetry(async () => {
|
|
763
776
|
const response = await fetch(`${this.apiUrl}/steps/${stepId}/`, {
|
|
764
777
|
method: 'PATCH',
|
|
765
778
|
headers: this.getHeaders(),
|
|
@@ -777,7 +790,7 @@ class ApiClient {
|
|
|
777
790
|
}
|
|
778
791
|
async resolveNextInstruction(stepId, instruction, aiModel) {
|
|
779
792
|
apiLogger.debug(`resolving next instruction: ${instruction}`);
|
|
780
|
-
return pRetry
|
|
793
|
+
return pRetry(async () => {
|
|
781
794
|
apiLogger.debug(`API client: Resolving next instruction for step ${stepId}`);
|
|
782
795
|
const cleanInstruction = cleanupInstructionElements(instruction);
|
|
783
796
|
const response = await fetch(`${this.apiUrl}/step-runners/${stepId}/run/`, {
|
|
@@ -799,7 +812,7 @@ class ApiClient {
|
|
|
799
812
|
});
|
|
800
813
|
}
|
|
801
814
|
async uploadScreenshot(screenshot_bytes) {
|
|
802
|
-
return pRetry
|
|
815
|
+
return pRetry(async () => {
|
|
803
816
|
const response = await fetch(`${this.apiUrl}/upload-screenshots/`, {
|
|
804
817
|
method: 'POST',
|
|
805
818
|
headers: {
|
|
@@ -819,7 +832,7 @@ class ApiClient {
|
|
|
819
832
|
}
|
|
820
833
|
async findStepByPrompt(prompt, scenarioName, url = '') {
|
|
821
834
|
apiLogger.debug(`Finding step by prompt: ${prompt} and scenario: ${scenarioName}`);
|
|
822
|
-
return pRetry
|
|
835
|
+
return pRetry(async () => {
|
|
823
836
|
const response = await fetch(`${this.apiUrl}/step-runners/find-step-by-prompt/`, {
|
|
824
837
|
method: 'POST',
|
|
825
838
|
headers: this.getHeaders(),
|
|
@@ -853,7 +866,7 @@ class ApiClient {
|
|
|
853
866
|
});
|
|
854
867
|
}
|
|
855
868
|
async resetStep(stepId) {
|
|
856
|
-
return pRetry
|
|
869
|
+
return pRetry(async () => {
|
|
857
870
|
const response = await fetch(`${this.apiUrl}/steps/${stepId}/reset/`, {
|
|
858
871
|
method: 'POST',
|
|
859
872
|
headers: this.getHeaders(),
|
|
@@ -865,7 +878,7 @@ class ApiClient {
|
|
|
865
878
|
async interactionToStep(scenarioName, interaction, position = -1) {
|
|
866
879
|
// Use POST /interaction-to-step/ endpoint
|
|
867
880
|
apiLogger.debug(`converting interaction #${interaction.interactionId} to step`);
|
|
868
|
-
return pRetry
|
|
881
|
+
return pRetry(async () => {
|
|
869
882
|
var _a, _b;
|
|
870
883
|
const response = await fetch(`${this.apiUrl}/interaction-to-step/`, {
|
|
871
884
|
method: 'POST',
|
|
@@ -899,7 +912,7 @@ class ApiClient {
|
|
|
899
912
|
async actionToPrompt(action2promptInput, aiModel) {
|
|
900
913
|
// Use POST /action-to-prompt/ endpoint
|
|
901
914
|
apiLogger.debug(`running action2prompt for step #${action2promptInput.step_id}`);
|
|
902
|
-
return pRetry
|
|
915
|
+
return pRetry(async () => {
|
|
903
916
|
const response = await fetch(`${this.apiUrl}/steps/${action2promptInput.step_id}/action_to_prompt/`, {
|
|
904
917
|
method: 'POST',
|
|
905
918
|
headers: this.getHeaders(),
|
|
@@ -930,7 +943,7 @@ class ApiClient {
|
|
|
930
943
|
async askAI(question, scenarioName, screenshot, aiModel) {
|
|
931
944
|
apiLogger.debug(`Asking AI question: "${question}", scenarioName: ${scenarioName}`);
|
|
932
945
|
apiLogger.debug(`headers: ${JSON.stringify(this.getHeaders())}`);
|
|
933
|
-
return pRetry
|
|
946
|
+
return pRetry(async () => {
|
|
934
947
|
const response = await fetch(`${this.apiUrl}/api/ask-ai/`, {
|
|
935
948
|
method: 'POST',
|
|
936
949
|
headers: this.getHeaders(),
|
|
@@ -982,6 +995,11 @@ const DEFAULT_PLAYWRIGHT_TIMEOUT_CONFIG = {
|
|
|
982
995
|
waitForNavigationInitialTimeout: 2000,
|
|
983
996
|
waitForNavigationNavigationTimeout: 7000,
|
|
984
997
|
waitForNavigationGlobalTimeout: 15000,
|
|
998
|
+
// wait for stability
|
|
999
|
+
waitForStabilityQuietTimeout: 2000,
|
|
1000
|
+
waitForStabilityInitialDelay: 500,
|
|
1001
|
+
waitForStabilityGlobalTimeout: 15000,
|
|
1002
|
+
waitForStabilityVerbose: false,
|
|
985
1003
|
};
|
|
986
1004
|
|
|
987
1005
|
({
|
|
@@ -1098,9 +1116,9 @@ class Highlighter {
|
|
|
1098
1116
|
proboLogger.debug('Injecting highlighter script...');
|
|
1099
1117
|
await page.evaluate(highlighterCode);
|
|
1100
1118
|
// Verify the script was injected correctly
|
|
1101
|
-
const verified = await page.evaluate(`
|
|
1102
|
-
//console.log('ProboLabs global:', window.ProboLabs);
|
|
1103
|
-
typeof window.ProboLabs?.highlight?.execute === 'function'
|
|
1119
|
+
const verified = await page.evaluate(`
|
|
1120
|
+
//console.log('ProboLabs global:', window.ProboLabs);
|
|
1121
|
+
typeof window.ProboLabs?.highlight?.execute === 'function'
|
|
1104
1122
|
`);
|
|
1105
1123
|
proboLogger.debug('Script injection verified:', verified);
|
|
1106
1124
|
}
|
|
@@ -1261,107 +1279,289 @@ class Highlighter {
|
|
|
1261
1279
|
}
|
|
1262
1280
|
}
|
|
1263
1281
|
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1282
|
+
const MAILINATOR_API_KEY = '5bfef31518e84dfbb861b36f59259695';
|
|
1283
|
+
const MAILINATOR_DOMAIN = 'probolabs.testinator.com';
|
|
1284
|
+
/**
|
|
1285
|
+
* OTP utility class for working with Mailinator API
|
|
1286
|
+
*/
|
|
1287
|
+
class OTP {
|
|
1288
|
+
/**
|
|
1289
|
+
* Fetches the last messages from Mailinator for a specific inbox
|
|
1290
|
+
* @param inbox - The inbox name to check (without @domain)
|
|
1291
|
+
* @param since - Unix timestamp to fetch messages since (optional)
|
|
1292
|
+
* @returns Promise<MailinatorMessage[]> - Array of messages
|
|
1293
|
+
*/
|
|
1294
|
+
static async fetchLastMessages(inbox, since) {
|
|
1295
|
+
try {
|
|
1296
|
+
// Use the correct Mailinator API endpoint structure
|
|
1297
|
+
const url = `https://api.mailinator.com/v2/domains/${MAILINATOR_DOMAIN}/inboxes/${inbox}`;
|
|
1298
|
+
const response = await fetch(url, {
|
|
1299
|
+
method: 'GET',
|
|
1300
|
+
headers: {
|
|
1301
|
+
'Authorization': `Bearer ${MAILINATOR_API_KEY}`,
|
|
1302
|
+
'Content-Type': 'application/json'
|
|
1303
|
+
}
|
|
1304
|
+
});
|
|
1305
|
+
if (!response.ok) {
|
|
1306
|
+
throw new Error(`Mailinator API error: ${response.status} ${response.statusText}`);
|
|
1307
|
+
}
|
|
1308
|
+
const data = await response.json();
|
|
1309
|
+
// The API returns messages in 'msgs' property, not 'messages'
|
|
1310
|
+
const messages = data.msgs || data.messages || data || [];
|
|
1311
|
+
// Filter messages by 'since' timestamp if provided
|
|
1312
|
+
if (since) {
|
|
1313
|
+
return messages.filter((message) => message.time >= since);
|
|
1314
|
+
}
|
|
1315
|
+
return messages;
|
|
1316
|
+
}
|
|
1317
|
+
catch (error) {
|
|
1318
|
+
console.error('Error fetching messages from Mailinator:', error);
|
|
1319
|
+
throw error;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Fetches a specific message by ID
|
|
1324
|
+
* @param messageId - The message ID
|
|
1325
|
+
* @returns Promise<MailinatorMessage> - The message details
|
|
1326
|
+
*/
|
|
1327
|
+
static async fetchMessage(messageId) {
|
|
1328
|
+
try {
|
|
1329
|
+
const url = `https://api.mailinator.com/v2/domains/${MAILINATOR_DOMAIN}/messages/${messageId}`;
|
|
1330
|
+
const response = await fetch(url, {
|
|
1331
|
+
method: 'GET',
|
|
1332
|
+
headers: {
|
|
1333
|
+
'Authorization': `Bearer ${MAILINATOR_API_KEY}`,
|
|
1334
|
+
'Content-Type': 'application/json'
|
|
1335
|
+
}
|
|
1336
|
+
});
|
|
1337
|
+
if (!response.ok) {
|
|
1338
|
+
throw new Error(`Mailinator API error: ${response.status} ${response.statusText}`);
|
|
1339
|
+
}
|
|
1340
|
+
return await response.json();
|
|
1341
|
+
}
|
|
1342
|
+
catch (error) {
|
|
1343
|
+
console.error('Error fetching message from Mailinator:', error);
|
|
1344
|
+
throw error;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Extracts OTP codes from message content
|
|
1349
|
+
* @param message - The message to extract OTP from
|
|
1350
|
+
* @returns string | null - The OTP code or null if not found
|
|
1351
|
+
*/
|
|
1352
|
+
static extractOTPFromMessage(message) {
|
|
1353
|
+
// Common OTP patterns - ordered by specificity (most specific first)
|
|
1354
|
+
const otpPatterns = [
|
|
1355
|
+
// Very specific patterns for common OTP formats
|
|
1356
|
+
/(?:verification\s+code|verification\s+number|6-digit\s+verification\s+code)[\s\S]*?(\d{6})/i,
|
|
1357
|
+
/(?:use\s+the\s+6-digit\s+verification\s+code\s+below)[\s\S]*?(\d{6})/i,
|
|
1358
|
+
/(?:code\s+below\s+to\s+complete)[\s\S]*?(\d{6})/i,
|
|
1359
|
+
/(?:verification\s+code)[\s\S]*?(\d{6})/i,
|
|
1360
|
+
// Patterns with context around the code
|
|
1361
|
+
/(?:OTP|code|verification code|verification):\s*(\d{4,8})/i,
|
|
1362
|
+
/(?:enter|use|input)\s+(?:the\s+)?(?:code|OTP|verification code):\s*(\d{4,8})/i,
|
|
1363
|
+
/(?:your\s+)?(?:verification\s+)?(?:code\s+is|OTP\s+is):\s*(\d{4,8})/i,
|
|
1364
|
+
/(?:code|OTP|pin):\s*(\d{4,8})/i,
|
|
1365
|
+
/(?:code|OTP)\s+is\s+(\d{4,8})/i,
|
|
1366
|
+
// Look for codes in specific contexts (avoiding URLs and tracking IDs)
|
|
1367
|
+
/(?:complete\s+your\s+sign-in)[\s\S]*?(\d{6})/i,
|
|
1368
|
+
/(?:valid\s+for\s+the\s+next)[\s\S]*?(\d{6})/i,
|
|
1369
|
+
// Fallback: 6-digit numbers (most common OTP length)
|
|
1370
|
+
/(?:^|\s)(\d{6})(?:\s|$)/i,
|
|
1371
|
+
// Last resort: 4-8 digit numbers (but avoid very long ones that are likely tracking IDs)
|
|
1372
|
+
/(?:^|\s)(\d{4,8})(?:\s|$)/i
|
|
1373
|
+
];
|
|
1374
|
+
// Helper function to check if a number is likely an OTP (not a tracking ID, phone number, etc.)
|
|
1375
|
+
const isValidOTP = (number) => {
|
|
1376
|
+
// Common OTP lengths
|
|
1377
|
+
if (number.length < 4 || number.length > 8)
|
|
1378
|
+
return false;
|
|
1379
|
+
// Only filter out very obvious non-OTPs
|
|
1380
|
+
// Avoid numbers that are all the same digit (unlikely to be OTPs)
|
|
1381
|
+
if (/^(\d)\1+$/.test(number))
|
|
1382
|
+
return false;
|
|
1383
|
+
// Avoid very long numbers that are clearly tracking IDs (but be more lenient)
|
|
1384
|
+
if (number.length >= 8 && parseInt(number) > 99999999)
|
|
1385
|
+
return false;
|
|
1386
|
+
return true;
|
|
1387
|
+
};
|
|
1388
|
+
// Helper function to extract OTP from text content
|
|
1389
|
+
const extractFromText = (text) => {
|
|
1390
|
+
if (!text)
|
|
1391
|
+
return null;
|
|
1392
|
+
for (const pattern of otpPatterns) {
|
|
1393
|
+
const match = text.match(pattern);
|
|
1394
|
+
if (match && match[1] && isValidOTP(match[1])) {
|
|
1395
|
+
return match[1];
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
return null;
|
|
1399
|
+
};
|
|
1400
|
+
// Helper function to strip HTML tags
|
|
1401
|
+
const stripHtml = (html) => {
|
|
1402
|
+
return html.replace(/<[^>]*>/g, '').replace(/&[^;]+;/g, ' ');
|
|
1403
|
+
};
|
|
1404
|
+
// 1. Try to extract from text body first (preferred)
|
|
1405
|
+
if (message.textBody) {
|
|
1406
|
+
const otp = extractFromText(message.textBody);
|
|
1407
|
+
if (otp)
|
|
1408
|
+
return otp;
|
|
1409
|
+
}
|
|
1410
|
+
// 2. Try to extract from HTML body (strip HTML tags first)
|
|
1411
|
+
if (message.htmlBody) {
|
|
1412
|
+
const plainText = stripHtml(message.htmlBody);
|
|
1413
|
+
const otp = extractFromText(plainText);
|
|
1414
|
+
if (otp)
|
|
1415
|
+
return otp;
|
|
1416
|
+
}
|
|
1417
|
+
// 3. Try to extract from message parts
|
|
1418
|
+
if (message.parts && message.parts.length > 0) {
|
|
1419
|
+
for (const part of message.parts) {
|
|
1420
|
+
if (part.body) {
|
|
1421
|
+
const otp = extractFromText(part.body);
|
|
1422
|
+
if (otp)
|
|
1423
|
+
return otp;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
// 4. Fallback: try to extract from subject
|
|
1428
|
+
const otp = extractFromText(message.subject);
|
|
1429
|
+
if (otp)
|
|
1430
|
+
return otp;
|
|
1431
|
+
return null;
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Gets the text content from a message for debugging/display purposes
|
|
1435
|
+
* @param message - The message to extract text from
|
|
1436
|
+
* @returns string - The text content of the message
|
|
1437
|
+
*/
|
|
1438
|
+
static getMessageTextContent(message) {
|
|
1439
|
+
// Helper function to strip HTML tags
|
|
1440
|
+
const stripHtml = (html) => {
|
|
1441
|
+
return html.replace(/<[^>]*>/g, '').replace(/&[^;]+;/g, ' ');
|
|
1442
|
+
};
|
|
1443
|
+
// Priority order: textBody > htmlBody > parts > subject
|
|
1444
|
+
if (message.textBody) {
|
|
1445
|
+
return message.textBody;
|
|
1446
|
+
}
|
|
1447
|
+
if (message.htmlBody) {
|
|
1448
|
+
return stripHtml(message.htmlBody);
|
|
1449
|
+
}
|
|
1450
|
+
if (message.parts && message.parts.length > 0) {
|
|
1451
|
+
return message.parts.map(part => part.body || '').join('\n');
|
|
1452
|
+
}
|
|
1453
|
+
return message.subject || '';
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Fetches messages from all inboxes in the domain
|
|
1457
|
+
* @param limit - Number of messages to fetch (default: 50)
|
|
1458
|
+
* @param sort - Sort order: 'ascending' or 'descending' (default: 'descending')
|
|
1459
|
+
* @param full - Whether to fetch full message content (default: false)
|
|
1460
|
+
* @returns Promise<MailinatorMessage[]> - Array of messages from all inboxes
|
|
1461
|
+
*/
|
|
1462
|
+
static async fetchAllInboxMessages(limit = 50, sort = 'descending', full = false) {
|
|
1463
|
+
try {
|
|
1464
|
+
const url = `https://api.mailinator.com/v2/domains/${MAILINATOR_DOMAIN}/inboxes/?limit=${limit}&sort=${sort}${full ? '&full=true' : ''}`;
|
|
1465
|
+
const response = await fetch(url, {
|
|
1466
|
+
method: 'GET',
|
|
1467
|
+
headers: {
|
|
1468
|
+
'Authorization': `Bearer ${MAILINATOR_API_KEY}`,
|
|
1469
|
+
'Content-Type': 'application/json'
|
|
1470
|
+
}
|
|
1471
|
+
});
|
|
1472
|
+
if (!response.ok) {
|
|
1473
|
+
throw new Error(`Mailinator API error: ${response.status} ${response.statusText}`);
|
|
1474
|
+
}
|
|
1475
|
+
const data = await response.json();
|
|
1476
|
+
return data.msgs || data.messages || data || [];
|
|
1477
|
+
}
|
|
1478
|
+
catch (error) {
|
|
1479
|
+
console.error('Error fetching messages from all inboxes:', error);
|
|
1480
|
+
throw error;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Waits for an OTP to arrive in the inbox and extracts it
|
|
1485
|
+
* @param inbox - The inbox name to monitor (optional - if not provided, searches all inboxes)
|
|
1486
|
+
* @param timeout - Maximum time to wait in milliseconds (default: 30000)
|
|
1487
|
+
* @param checkInterval - How often to check in milliseconds (default: 1000)
|
|
1488
|
+
* @param checkRecentMessagesSinceMs - When > 0, check messages from the last X milliseconds and return the most recent OTP (default: 0)
|
|
1489
|
+
* @returns Promise<string | null> - The extracted OTP code or null if timeout/no OTP found
|
|
1490
|
+
*/
|
|
1491
|
+
static async waitForOTP(inbox, timeout = 30000, checkInterval = 1000, checkRecentMessagesSinceMs = 0) {
|
|
1492
|
+
const startTime = Date.now();
|
|
1493
|
+
// If checkRecentMessagesSinceMs > 0, check for recent messages first
|
|
1494
|
+
if (checkRecentMessagesSinceMs > 0) {
|
|
1495
|
+
console.log(`Checking for OTP in messages from the last ${checkRecentMessagesSinceMs}ms...`);
|
|
1496
|
+
const recentMessagesCutoff = Date.now() - checkRecentMessagesSinceMs;
|
|
1497
|
+
const recentMessages = inbox
|
|
1498
|
+
? await OTP.fetchLastMessages(inbox)
|
|
1499
|
+
: await OTP.fetchAllInboxMessages();
|
|
1500
|
+
// Filter messages from the specified time window
|
|
1501
|
+
const messagesFromWindow = recentMessages.filter(msg => msg.time >= recentMessagesCutoff);
|
|
1502
|
+
// Sort by time (most recent first) and check for OTP
|
|
1503
|
+
const sortedMessages = messagesFromWindow.sort((a, b) => b.time - a.time);
|
|
1504
|
+
for (const message of sortedMessages) {
|
|
1505
|
+
let otp = OTP.extractOTPFromMessage(message);
|
|
1506
|
+
if (otp) {
|
|
1507
|
+
console.log(`Found OTP in recent message: ${otp}`);
|
|
1508
|
+
return otp;
|
|
1509
|
+
}
|
|
1510
|
+
// If no OTP found in summary, fetch full message details
|
|
1511
|
+
try {
|
|
1512
|
+
const fullMessage = await OTP.fetchMessage(message.id);
|
|
1513
|
+
otp = OTP.extractOTPFromMessage(fullMessage);
|
|
1514
|
+
if (otp) {
|
|
1515
|
+
console.log(`Found OTP in recent full message: ${otp}`);
|
|
1516
|
+
return otp;
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
catch (error) {
|
|
1520
|
+
console.warn(`Error fetching full message ${message.id}:`, error);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
console.log(`No OTP found in recent messages, starting to monitor for new messages...`);
|
|
1524
|
+
}
|
|
1525
|
+
// Get initial messages for monitoring new ones (from specific inbox or all inboxes)
|
|
1526
|
+
const initialMessages = inbox
|
|
1527
|
+
? await OTP.fetchLastMessages(inbox)
|
|
1528
|
+
: await OTP.fetchAllInboxMessages();
|
|
1529
|
+
const initialMessageIds = new Set(initialMessages.map(msg => msg.id));
|
|
1530
|
+
while (Date.now() - startTime < timeout) {
|
|
1531
|
+
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
|
1532
|
+
try {
|
|
1533
|
+
// Get current messages (from specific inbox or all inboxes)
|
|
1534
|
+
const currentMessages = inbox
|
|
1535
|
+
? await OTP.fetchLastMessages(inbox)
|
|
1536
|
+
: await OTP.fetchAllInboxMessages();
|
|
1537
|
+
const newMessages = currentMessages.filter(msg => !initialMessageIds.has(msg.id));
|
|
1538
|
+
if (newMessages.length > 0) {
|
|
1539
|
+
// Get the first new message and try to extract OTP
|
|
1540
|
+
const newMessage = newMessages[0];
|
|
1541
|
+
// First try to extract OTP from the message summary (faster)
|
|
1542
|
+
let otp = OTP.extractOTPFromMessage(newMessage);
|
|
1543
|
+
if (otp) {
|
|
1544
|
+
return otp;
|
|
1545
|
+
}
|
|
1546
|
+
// If no OTP found in summary, fetch full message details
|
|
1547
|
+
const fullMessage = await OTP.fetchMessage(newMessage.id);
|
|
1548
|
+
otp = OTP.extractOTPFromMessage(fullMessage);
|
|
1549
|
+
if (otp) {
|
|
1550
|
+
return otp;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
catch (error) {
|
|
1555
|
+
console.warn('Error checking for new messages:', error);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
return null; // Timeout reached or no OTP found
|
|
1559
|
+
}
|
|
1356
1560
|
}
|
|
1357
1561
|
|
|
1358
|
-
var pRetryExports = requirePRetry();
|
|
1359
|
-
var pRetry = /*@__PURE__*/getDefaultExportFromCjs(pRetryExports);
|
|
1360
|
-
|
|
1361
1562
|
class ProboPlaywright {
|
|
1362
1563
|
constructor(config = DEFAULT_PLAYWRIGHT_TIMEOUT_CONFIG, page = null) {
|
|
1363
1564
|
this.page = null;
|
|
1364
|
-
this.lastNavigationTime = null;
|
|
1365
1565
|
// Merge provided config with defaults to ensure all properties are defined
|
|
1366
1566
|
this.config = {
|
|
1367
1567
|
...DEFAULT_PLAYWRIGHT_TIMEOUT_CONFIG,
|
|
@@ -1380,15 +1580,8 @@ class ProboPlaywright {
|
|
|
1380
1580
|
if (this.page) {
|
|
1381
1581
|
this.page.setDefaultNavigationTimeout(this.config.playwrightNavigationTimeout);
|
|
1382
1582
|
this.page.setDefaultTimeout(this.config.playwrightActionTimeout);
|
|
1383
|
-
// start the nav handler listener
|
|
1384
|
-
this.page.on("framenavigated", this.onFrameNav.bind(this));
|
|
1385
1583
|
}
|
|
1386
1584
|
}
|
|
1387
|
-
onFrameNav(frame) {
|
|
1388
|
-
// if (frame === this.page?.mainFrame()) {
|
|
1389
|
-
// this.lastNavigationTime = Date.now();
|
|
1390
|
-
// }
|
|
1391
|
-
}
|
|
1392
1585
|
/**
|
|
1393
1586
|
* Executes a single step in the test scenario with the specified action on the target element.
|
|
1394
1587
|
* Handles iframe navigation, element highlighting, and various Playwright actions like click, fill, validate, etc.
|
|
@@ -1407,7 +1600,6 @@ class ProboPlaywright {
|
|
|
1407
1600
|
if (action === PlaywrightAction.VISIT_URL || action === PlaywrightAction.VISIT_BASE_URL) {
|
|
1408
1601
|
try {
|
|
1409
1602
|
await this.page.goto(argument, { timeout: this.config.playwrightNavigationTimeout });
|
|
1410
|
-
// await handlePotentialNavigation(this.page);
|
|
1411
1603
|
}
|
|
1412
1604
|
catch (e) {
|
|
1413
1605
|
throw new Error(`Failed to navigate to ${argument}`);
|
|
@@ -1449,7 +1641,6 @@ class ProboPlaywright {
|
|
|
1449
1641
|
case PlaywrightAction.CHECK_CHECKBOX:
|
|
1450
1642
|
case PlaywrightAction.SELECT_RADIO:
|
|
1451
1643
|
await this.robustClick(locator);
|
|
1452
|
-
// await handlePotentialNavigation(this.page);
|
|
1453
1644
|
break;
|
|
1454
1645
|
case PlaywrightAction.FILL_IN:
|
|
1455
1646
|
await this.robustFill(locator, argument);
|
|
@@ -1457,6 +1648,20 @@ class ProboPlaywright {
|
|
|
1457
1648
|
case PlaywrightAction.SELECT_DROPDOWN:
|
|
1458
1649
|
await locator.selectOption(argument);
|
|
1459
1650
|
break;
|
|
1651
|
+
case PlaywrightAction.SET_SLIDER:
|
|
1652
|
+
await this.setSliderValue(locator, argument);
|
|
1653
|
+
break;
|
|
1654
|
+
case PlaywrightAction.WAIT_FOR_OTP:
|
|
1655
|
+
// till we figure out how to get the inbox name we will wait for ANY OTP in all inboxes
|
|
1656
|
+
const otp = await OTP.waitForOTP();
|
|
1657
|
+
if (otp) {
|
|
1658
|
+
console.log(`✅ OTP found: ${otp}`);
|
|
1659
|
+
await locator.fill(otp);
|
|
1660
|
+
}
|
|
1661
|
+
else {
|
|
1662
|
+
console.log(`❌ OTP not found`);
|
|
1663
|
+
}
|
|
1664
|
+
break;
|
|
1460
1665
|
case PlaywrightAction.ASSERT_CONTAINS_VALUE:
|
|
1461
1666
|
const containerText = await this.getTextValue(locator);
|
|
1462
1667
|
if (!matchRegex(containerText, argument)) {
|
|
@@ -1555,26 +1760,26 @@ class ProboPlaywright {
|
|
|
1555
1760
|
await locator.evaluate((el) => {
|
|
1556
1761
|
const overlay = el.ownerDocument.createElement('div');
|
|
1557
1762
|
overlay.id = 'highlight-overlay';
|
|
1558
|
-
overlay.style.cssText = `
|
|
1559
|
-
position: fixed;
|
|
1560
|
-
top: 0;
|
|
1561
|
-
left: 0;
|
|
1562
|
-
width: 100%;
|
|
1563
|
-
height: 100%;
|
|
1564
|
-
pointer-events: none;
|
|
1565
|
-
z-index: 2147483647;
|
|
1763
|
+
overlay.style.cssText = `
|
|
1764
|
+
position: fixed;
|
|
1765
|
+
top: 0;
|
|
1766
|
+
left: 0;
|
|
1767
|
+
width: 100%;
|
|
1768
|
+
height: 100%;
|
|
1769
|
+
pointer-events: none;
|
|
1770
|
+
z-index: 2147483647;
|
|
1566
1771
|
`;
|
|
1567
1772
|
el.ownerDocument.body.appendChild(overlay);
|
|
1568
1773
|
const bbox = el.getBoundingClientRect();
|
|
1569
1774
|
const highlight = el.ownerDocument.createElement('div');
|
|
1570
|
-
highlight.style.cssText = `
|
|
1571
|
-
position: fixed;
|
|
1572
|
-
left: ${bbox.x}px;
|
|
1573
|
-
top: ${bbox.y}px;
|
|
1574
|
-
width: ${bbox.width}px;
|
|
1575
|
-
height: ${bbox.height}px;
|
|
1576
|
-
border: 2px solid rgb(255, 0, 0);
|
|
1577
|
-
transition: all 0.2s ease-in-out;
|
|
1775
|
+
highlight.style.cssText = `
|
|
1776
|
+
position: fixed;
|
|
1777
|
+
left: ${bbox.x}px;
|
|
1778
|
+
top: ${bbox.y}px;
|
|
1779
|
+
width: ${bbox.width}px;
|
|
1780
|
+
height: ${bbox.height}px;
|
|
1781
|
+
border: 2px solid rgb(255, 0, 0);
|
|
1782
|
+
transition: all 0.2s ease-in-out;
|
|
1578
1783
|
`;
|
|
1579
1784
|
overlay.appendChild(highlight);
|
|
1580
1785
|
}, { timeout: 500 });
|
|
@@ -1588,19 +1793,19 @@ class ProboPlaywright {
|
|
|
1588
1793
|
if (overlay) {
|
|
1589
1794
|
const bbox = el.getBoundingClientRect();
|
|
1590
1795
|
const annotationEl = el.ownerDocument.createElement('div');
|
|
1591
|
-
annotationEl.style.cssText = `
|
|
1592
|
-
position: fixed;
|
|
1593
|
-
left: ${bbox.x}px;
|
|
1594
|
-
top: ${bbox.y - 25}px;
|
|
1595
|
-
padding: 2px 6px;
|
|
1596
|
-
background-color: rgba(255, 255, 0, 0.6);
|
|
1597
|
-
color: black;
|
|
1598
|
-
font-size: 16px;
|
|
1599
|
-
font-family: 'Courier New', Courier, monospace;
|
|
1600
|
-
font-weight: bold;
|
|
1601
|
-
border-radius: 3px;
|
|
1602
|
-
pointer-events: none;
|
|
1603
|
-
z-index: 2147483647;
|
|
1796
|
+
annotationEl.style.cssText = `
|
|
1797
|
+
position: fixed;
|
|
1798
|
+
left: ${bbox.x}px;
|
|
1799
|
+
top: ${bbox.y - 25}px;
|
|
1800
|
+
padding: 2px 6px;
|
|
1801
|
+
background-color: rgba(255, 255, 0, 0.6);
|
|
1802
|
+
color: black;
|
|
1803
|
+
font-size: 16px;
|
|
1804
|
+
font-family: 'Courier New', Courier, monospace;
|
|
1805
|
+
font-weight: bold;
|
|
1806
|
+
border-radius: 3px;
|
|
1807
|
+
pointer-events: none;
|
|
1808
|
+
z-index: 2147483647;
|
|
1604
1809
|
`;
|
|
1605
1810
|
annotationEl.textContent = annotation;
|
|
1606
1811
|
// If element is too close to top of window, position annotation below
|
|
@@ -1747,11 +1952,18 @@ class ProboPlaywright {
|
|
|
1747
1952
|
return textValue.trim().replace(/\s+/g, ' ');
|
|
1748
1953
|
}
|
|
1749
1954
|
;
|
|
1955
|
+
async setSliderValue(locator, value) {
|
|
1956
|
+
await locator.evaluate((el, value) => {
|
|
1957
|
+
el.value = value;
|
|
1958
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1959
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1960
|
+
}, value, { timeout: this.config.playwrightActionTimeout });
|
|
1961
|
+
}
|
|
1750
1962
|
} /* class ProboPlaywright */
|
|
1751
1963
|
|
|
1752
1964
|
/**
|
|
1753
|
-
* Global navigation tracker that monitors page navigation events
|
|
1754
|
-
*
|
|
1965
|
+
* Global navigation tracker that monitors page navigation events and network activity
|
|
1966
|
+
* using CDP (Chrome DevTools Protocol) for comprehensive network monitoring
|
|
1755
1967
|
*
|
|
1756
1968
|
* This is a singleton class - only one instance can exist at a time
|
|
1757
1969
|
*/
|
|
@@ -1760,75 +1972,291 @@ class NavTracker {
|
|
|
1760
1972
|
* Private constructor - use getInstance() to get the singleton instance
|
|
1761
1973
|
*/
|
|
1762
1974
|
constructor(page, options = {}) {
|
|
1763
|
-
var _a;
|
|
1975
|
+
var _a, _b, _c, _d, _f, _g, _h;
|
|
1764
1976
|
this.navigationCount = 0;
|
|
1765
1977
|
this.lastNavTime = null;
|
|
1766
1978
|
this.isListening = false;
|
|
1979
|
+
this.client = null;
|
|
1980
|
+
this.inflight = new Set();
|
|
1981
|
+
this.lastHardNavAt = 0;
|
|
1982
|
+
this.lastSoftNavAt = 0;
|
|
1983
|
+
this.lastNetworkActivityAt = 0;
|
|
1984
|
+
this.totalRequestsTracked = 0;
|
|
1985
|
+
// Define relevant resource types and content types
|
|
1986
|
+
this.RELEVANT_RESOURCE_TYPES = [
|
|
1987
|
+
'Document',
|
|
1988
|
+
'Stylesheet',
|
|
1989
|
+
'Image',
|
|
1990
|
+
'Font',
|
|
1991
|
+
'Script',
|
|
1992
|
+
'XHR',
|
|
1993
|
+
'Fetch'
|
|
1994
|
+
];
|
|
1995
|
+
this.RELEVANT_CONTENT_TYPES = [
|
|
1996
|
+
'text/html',
|
|
1997
|
+
'text/css',
|
|
1998
|
+
'application/javascript',
|
|
1999
|
+
'image/',
|
|
2000
|
+
'font/',
|
|
2001
|
+
'application/json',
|
|
2002
|
+
];
|
|
2003
|
+
// Additional patterns to filter out
|
|
2004
|
+
this.IGNORED_URL_PATTERNS = [
|
|
2005
|
+
// Analytics and tracking
|
|
2006
|
+
'analytics',
|
|
2007
|
+
'tracking',
|
|
2008
|
+
'telemetry',
|
|
2009
|
+
'beacon',
|
|
2010
|
+
'metrics',
|
|
2011
|
+
// Ad-related
|
|
2012
|
+
'doubleclick',
|
|
2013
|
+
'adsystem',
|
|
2014
|
+
'adserver',
|
|
2015
|
+
'advertising',
|
|
2016
|
+
// Social media widgets
|
|
2017
|
+
'facebook.com/plugins',
|
|
2018
|
+
'platform.twitter',
|
|
2019
|
+
'linkedin.com/embed',
|
|
2020
|
+
// Live chat and support
|
|
2021
|
+
'livechat',
|
|
2022
|
+
'zendesk',
|
|
2023
|
+
'intercom',
|
|
2024
|
+
'crisp.chat',
|
|
2025
|
+
'hotjar',
|
|
2026
|
+
// Push notifications
|
|
2027
|
+
'push-notifications',
|
|
2028
|
+
'onesignal',
|
|
2029
|
+
'pushwoosh',
|
|
2030
|
+
// Background sync/heartbeat
|
|
2031
|
+
'heartbeat',
|
|
2032
|
+
'ping',
|
|
2033
|
+
'alive',
|
|
2034
|
+
// WebRTC and streaming
|
|
2035
|
+
'webrtc',
|
|
2036
|
+
'rtmp://',
|
|
2037
|
+
'wss://',
|
|
2038
|
+
// Common CDNs for dynamic content
|
|
2039
|
+
'cloudfront.net',
|
|
2040
|
+
'fastly.net',
|
|
2041
|
+
];
|
|
1767
2042
|
this.page = page;
|
|
1768
|
-
this.
|
|
2043
|
+
this.waitForStabilityQuietTimeout = (_a = options.waitForStabilityQuietTimeout) !== null && _a !== void 0 ? _a : 2000;
|
|
2044
|
+
this.waitForStabilityInitialDelay = (_b = options.waitForStabilityInitialDelay) !== null && _b !== void 0 ? _b : 500;
|
|
2045
|
+
this.waitForStabilityGlobalTimeout = (_c = options.waitForStabilityGlobalTimeout) !== null && _c !== void 0 ? _c : 15000;
|
|
2046
|
+
this.pollMs = (_d = options.pollMs) !== null && _d !== void 0 ? _d : 100;
|
|
2047
|
+
this.maxInflight = (_f = options.maxInflight) !== null && _f !== void 0 ? _f : 0;
|
|
2048
|
+
this.inflightGraceMs = (_g = options.inflightGraceMs) !== null && _g !== void 0 ? _g : 4000;
|
|
2049
|
+
this.waitForStabilityVerbose = (_h = options.waitForStabilityVerbose) !== null && _h !== void 0 ? _h : false;
|
|
2050
|
+
proboLogger.debug(`NavTracker constructor set values: quietTimeout=${this.waitForStabilityQuietTimeout}, initialDelay=${this.waitForStabilityInitialDelay}, globalTimeout=${this.waitForStabilityGlobalTimeout}, verbose=${this.waitForStabilityVerbose}`);
|
|
1769
2051
|
this.instanceId = Math.random().toString(36).substr(2, 9);
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
};
|
|
1777
|
-
// Auto-start the tracker when first instantiated
|
|
1778
|
-
this.start();
|
|
2052
|
+
// Initialize timestamps
|
|
2053
|
+
const now = Date.now();
|
|
2054
|
+
this.lastHardNavAt = now;
|
|
2055
|
+
this.lastSoftNavAt = now;
|
|
2056
|
+
this.lastNetworkActivityAt = now;
|
|
2057
|
+
// Note: start() is called asynchronously in getInstance() to ensure proper initialization
|
|
1779
2058
|
}
|
|
1780
2059
|
/**
|
|
1781
|
-
* Start listening for navigation events (private method)
|
|
2060
|
+
* Start listening for navigation and network events using CDP (private method)
|
|
1782
2061
|
*/
|
|
1783
|
-
start() {
|
|
2062
|
+
async start() {
|
|
1784
2063
|
if (this.isListening) {
|
|
1785
2064
|
proboLogger.debug(`NavTracker[${this.instanceId}]: already listening, ignoring start()`);
|
|
1786
2065
|
return;
|
|
1787
2066
|
}
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
2067
|
+
try {
|
|
2068
|
+
// Set up CDP session
|
|
2069
|
+
this.client = await this.page.context().newCDPSession(this.page);
|
|
2070
|
+
await this.client.send('Page.enable');
|
|
2071
|
+
await this.client.send('Network.enable');
|
|
2072
|
+
await this.client.send('Runtime.enable');
|
|
2073
|
+
await this.client.send('Page.setLifecycleEventsEnabled', { enabled: true });
|
|
2074
|
+
// Set up navigation event handlers
|
|
2075
|
+
this.client.on('Page.frameNavigated', (e) => {
|
|
2076
|
+
var _a;
|
|
2077
|
+
if (!((_a = e.frame) === null || _a === void 0 ? void 0 : _a.parentId)) { // main frame has no parentId
|
|
2078
|
+
this.lastHardNavAt = Date.now();
|
|
2079
|
+
this.navigationCount++;
|
|
2080
|
+
this.lastNavTime = Date.now();
|
|
2081
|
+
if (this.waitForStabilityVerbose) {
|
|
2082
|
+
proboLogger.debug(`NavTracker[${this.instanceId}]: Hard navigation detected at ${this.lastHardNavAt}`);
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
});
|
|
2086
|
+
this.client.on('Page.navigatedWithinDocument', (_e) => {
|
|
2087
|
+
this.lastSoftNavAt = Date.now();
|
|
2088
|
+
if (this.waitForStabilityVerbose) {
|
|
2089
|
+
proboLogger.debug(`NavTracker[${this.instanceId}]: Soft navigation detected at ${this.lastSoftNavAt}`);
|
|
2090
|
+
}
|
|
2091
|
+
});
|
|
2092
|
+
// Set up network event handlers
|
|
2093
|
+
this.client.on('Network.requestWillBeSent', (e) => {
|
|
2094
|
+
this.onNetworkRequest(e);
|
|
2095
|
+
});
|
|
2096
|
+
this.client.on('Network.loadingFinished', (e) => {
|
|
2097
|
+
this.onNetworkResponse(e, 'finished');
|
|
2098
|
+
});
|
|
2099
|
+
this.client.on('Network.loadingFailed', (e) => {
|
|
2100
|
+
this.onNetworkResponse(e, 'failed');
|
|
2101
|
+
});
|
|
2102
|
+
this.isListening = true;
|
|
2103
|
+
proboLogger.debug(`NavTracker[${this.instanceId}]: started CDP-based monitoring`);
|
|
2104
|
+
}
|
|
2105
|
+
catch (error) {
|
|
2106
|
+
proboLogger.error(`NavTracker[${this.instanceId}]: Failed to start CDP monitoring: ${error}`);
|
|
2107
|
+
// Fall back to basic navigation tracking
|
|
2108
|
+
this.page.on("framenavigated", (frame) => {
|
|
2109
|
+
if (frame === this.page.mainFrame()) {
|
|
2110
|
+
this.navigationCount++;
|
|
2111
|
+
this.lastNavTime = Date.now();
|
|
2112
|
+
this.lastHardNavAt = Date.now();
|
|
2113
|
+
}
|
|
2114
|
+
});
|
|
2115
|
+
this.isListening = true;
|
|
2116
|
+
}
|
|
1791
2117
|
}
|
|
1792
2118
|
/**
|
|
1793
|
-
* Stop listening for navigation events (private method)
|
|
2119
|
+
* Stop listening for navigation and network events (private method)
|
|
1794
2120
|
*/
|
|
1795
2121
|
stop() {
|
|
1796
2122
|
if (!this.isListening) {
|
|
1797
2123
|
proboLogger.debug(`NavTracker[${this.instanceId}]: not listening, ignoring stop()`);
|
|
1798
2124
|
return;
|
|
1799
2125
|
}
|
|
1800
|
-
|
|
2126
|
+
try {
|
|
2127
|
+
if (this.client) {
|
|
2128
|
+
// Check if the page is still available before detaching
|
|
2129
|
+
if (this.page && !this.page.isClosed()) {
|
|
2130
|
+
this.client.detach();
|
|
2131
|
+
}
|
|
2132
|
+
else {
|
|
2133
|
+
proboLogger.debug(`NavTracker[${this.instanceId}]: Page is closed, skipping CDP detach`);
|
|
2134
|
+
}
|
|
2135
|
+
this.client = null;
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
catch (error) {
|
|
2139
|
+
proboLogger.debug(`NavTracker[${this.instanceId}]: Error detaching CDP client: ${error}`);
|
|
2140
|
+
}
|
|
1801
2141
|
this.isListening = false;
|
|
1802
|
-
proboLogger.debug(`NavTracker[${this.instanceId}]: stopped
|
|
2142
|
+
proboLogger.debug(`NavTracker[${this.instanceId}]: stopped CDP monitoring`);
|
|
1803
2143
|
}
|
|
1804
2144
|
/**
|
|
1805
|
-
*
|
|
2145
|
+
* Handle network request events
|
|
2146
|
+
*/
|
|
2147
|
+
onNetworkRequest(e) {
|
|
2148
|
+
var _a, _b, _c;
|
|
2149
|
+
const requestType = e.type;
|
|
2150
|
+
const url = (_b = (_a = e.request) === null || _a === void 0 ? void 0 : _a.url) !== null && _b !== void 0 ? _b : '';
|
|
2151
|
+
// Filter by resource type
|
|
2152
|
+
if (!this.RELEVANT_RESOURCE_TYPES.includes(requestType)) {
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
// Filter out streaming, websocket, and other real-time requests
|
|
2156
|
+
if (['WebSocket', 'EventSource', 'Media', 'Manifest', 'Other'].includes(requestType)) {
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
// Filter out by URL patterns
|
|
2160
|
+
const urlLower = url.toLowerCase();
|
|
2161
|
+
if (this.IGNORED_URL_PATTERNS.some(pattern => urlLower.includes(pattern))) {
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
// Filter out data URLs and blob URLs
|
|
2165
|
+
if (urlLower.startsWith('data:') || urlLower.startsWith('blob:')) {
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
// Filter out requests with certain headers
|
|
2169
|
+
const headers = ((_c = e.request) === null || _c === void 0 ? void 0 : _c.headers) || {};
|
|
2170
|
+
if (headers['purpose'] === 'prefetch' || ['video', 'audio'].includes(headers['sec-fetch-dest'])) {
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
this.inflight.add(e.requestId);
|
|
2174
|
+
this.totalRequestsTracked++;
|
|
2175
|
+
this.lastNetworkActivityAt = Date.now();
|
|
2176
|
+
if (this.waitForStabilityVerbose) {
|
|
2177
|
+
proboLogger.debug(`NavTracker[${this.instanceId}]: Network request started: ${requestType} - ${url}`);
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2181
|
+
* Handle network response events
|
|
2182
|
+
*/
|
|
2183
|
+
onNetworkResponse(e, status) {
|
|
2184
|
+
const requestId = e.requestId;
|
|
2185
|
+
if (!this.inflight.has(requestId)) {
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
this.inflight.delete(requestId);
|
|
2189
|
+
this.lastNetworkActivityAt = Date.now();
|
|
2190
|
+
if (this.waitForStabilityVerbose) {
|
|
2191
|
+
proboLogger.debug(`NavTracker[${this.instanceId}]: Network request ${status} (${this.inflight.size} remaining)`);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* Check if navigation and network activity has stabilized
|
|
1806
2196
|
*/
|
|
1807
2197
|
hasNavigationStabilized() {
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
2198
|
+
const now = Date.now();
|
|
2199
|
+
// Use the most recent activity timestamp
|
|
2200
|
+
const lastActivityAt = Math.max(this.lastHardNavAt, this.lastSoftNavAt, this.lastNetworkActivityAt);
|
|
2201
|
+
const quietSinceMs = now - lastActivityAt;
|
|
2202
|
+
const inflightOk = this.inflight.size <= this.maxInflight || (now - this.lastHardNavAt) > this.inflightGraceMs;
|
|
2203
|
+
const isStabilized = quietSinceMs >= this.waitForStabilityQuietTimeout && inflightOk;
|
|
2204
|
+
if (this.waitForStabilityVerbose) {
|
|
2205
|
+
proboLogger.debug(`NavTracker[${this.instanceId}]: hasNavigationStabilized() - quietSinceMs=${quietSinceMs}ms, waitForStabilityQuietTimeout=${this.waitForStabilityQuietTimeout}ms, inflight=${this.inflight.size}, inflightOk=${inflightOk}, stabilized=${isStabilized}`);
|
|
1811
2206
|
}
|
|
1812
|
-
const timeSinceLastNav = Date.now() - this.lastNavTime;
|
|
1813
|
-
const isStabilized = timeSinceLastNav >= this.stabilizationTimeout;
|
|
1814
|
-
proboLogger.debug(`NavTracker[${this.instanceId}]: hasNavigationStabilized() - timeSinceLastNav=${timeSinceLastNav}ms, stabilizationTimeout=${this.stabilizationTimeout}ms, stabilized=${isStabilized}`);
|
|
1815
2207
|
return isStabilized;
|
|
1816
2208
|
}
|
|
1817
2209
|
/**
|
|
1818
|
-
* Wait for navigation to stabilize
|
|
1819
|
-
*
|
|
1820
|
-
* the latest navigation happened at least stabilizationTimeout ms ago
|
|
2210
|
+
* Wait for navigation and network activity to stabilize
|
|
2211
|
+
* Uses CDP-based monitoring for comprehensive network activity tracking
|
|
1821
2212
|
*/
|
|
1822
2213
|
async waitForNavigationToStabilize() {
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
2214
|
+
const now = Date.now();
|
|
2215
|
+
const lastActivityAt = Math.max(this.lastHardNavAt, this.lastSoftNavAt, this.lastNetworkActivityAt);
|
|
2216
|
+
const timeSinceLastActivity = now - lastActivityAt;
|
|
2217
|
+
proboLogger.debug(`NavTracker[${this.instanceId}]: waiting for navigation and network to stabilize (quietTimeout: ${this.waitForStabilityQuietTimeout}ms, initialDelay: ${this.waitForStabilityInitialDelay}ms, globalTimeout: ${this.waitForStabilityGlobalTimeout}ms, verbose: ${this.waitForStabilityVerbose}, lastActivity: ${timeSinceLastActivity}ms ago)`);
|
|
2218
|
+
// Ensure CDP monitoring is properly initialized
|
|
2219
|
+
if (!this.isListening) {
|
|
2220
|
+
proboLogger.warn(`NavTracker[${this.instanceId}]: CDP monitoring not initialized, initializing now...`);
|
|
2221
|
+
await this.start();
|
|
2222
|
+
}
|
|
2223
|
+
const startTime = Date.now();
|
|
2224
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
2225
|
+
try {
|
|
2226
|
+
// Initial delay to catch any new network activity triggered by user actions
|
|
2227
|
+
if (this.waitForStabilityInitialDelay > 0) {
|
|
2228
|
+
if (this.waitForStabilityVerbose) {
|
|
2229
|
+
proboLogger.debug(`NavTracker[${this.instanceId}]: initial delay of ${this.waitForStabilityInitialDelay}ms to catch new network activity`);
|
|
2230
|
+
}
|
|
2231
|
+
await sleep(this.waitForStabilityInitialDelay);
|
|
2232
|
+
}
|
|
2233
|
+
// Wait a short time to catch any missed events
|
|
2234
|
+
await sleep(100);
|
|
2235
|
+
// Main stabilization loop
|
|
2236
|
+
while (true) {
|
|
2237
|
+
const now = Date.now();
|
|
2238
|
+
// Check for timeout
|
|
2239
|
+
if (now - startTime > this.waitForStabilityGlobalTimeout) {
|
|
2240
|
+
proboLogger.warn(`NavTracker[${this.instanceId}]: Timeout reached after ${this.waitForStabilityGlobalTimeout}ms with ${this.inflight.size} pending requests`);
|
|
2241
|
+
break;
|
|
2242
|
+
}
|
|
2243
|
+
// Check if stabilized
|
|
2244
|
+
if (this.hasNavigationStabilized()) {
|
|
2245
|
+
const quietSinceMs = now - Math.max(this.lastHardNavAt, this.lastSoftNavAt, this.lastNetworkActivityAt);
|
|
2246
|
+
proboLogger.debug(`NavTracker[${this.instanceId}]: Page stabilized after ${quietSinceMs}ms of quiet time`);
|
|
2247
|
+
break;
|
|
2248
|
+
}
|
|
2249
|
+
// Log progress every 2 seconds in verbose mode
|
|
2250
|
+
if (this.waitForStabilityVerbose && now % 2000 < this.pollMs) {
|
|
2251
|
+
const quietSinceMs = now - Math.max(this.lastHardNavAt, this.lastSoftNavAt, this.lastNetworkActivityAt);
|
|
2252
|
+
proboLogger.debug(`NavTracker[${this.instanceId}]: Status - quiet=${quietSinceMs}ms/${this.waitForStabilityQuietTimeout}ms, inflight=${this.inflight.size}/${this.maxInflight}`);
|
|
2253
|
+
}
|
|
2254
|
+
await sleep(this.pollMs);
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
catch (error) {
|
|
2258
|
+
proboLogger.error(`NavTracker[${this.instanceId}]: Error during stabilization: ${error}`);
|
|
1830
2259
|
}
|
|
1831
|
-
proboLogger.debug(`NavTracker[${this.instanceId}]: navigation has stabilized`);
|
|
1832
2260
|
}
|
|
1833
2261
|
// ============================================================================
|
|
1834
2262
|
// SINGLETON METHODS
|
|
@@ -1839,19 +2267,58 @@ class NavTracker {
|
|
|
1839
2267
|
* @param options Optional configuration
|
|
1840
2268
|
* @returns The singleton NavTracker instance
|
|
1841
2269
|
*/
|
|
1842
|
-
static getInstance(page, options) {
|
|
2270
|
+
static async getInstance(page, options) {
|
|
2271
|
+
proboLogger.debug(`NavTracker.getInstance called with options:`, options);
|
|
1843
2272
|
if (!NavTracker.instance) {
|
|
1844
2273
|
if (!page) {
|
|
1845
2274
|
throw new Error('NavTracker: Page is required for first instance creation');
|
|
1846
2275
|
}
|
|
1847
2276
|
NavTracker.instance = new NavTracker(page, options);
|
|
1848
|
-
|
|
2277
|
+
await NavTracker.instance.start();
|
|
2278
|
+
proboLogger.debug(`NavTracker: created new singleton instance with options:`, options);
|
|
1849
2279
|
}
|
|
1850
2280
|
else {
|
|
1851
|
-
|
|
2281
|
+
// Update existing instance with new options
|
|
2282
|
+
if (options) {
|
|
2283
|
+
if (options.waitForStabilityQuietTimeout !== undefined) {
|
|
2284
|
+
NavTracker.instance.waitForStabilityQuietTimeout = options.waitForStabilityQuietTimeout;
|
|
2285
|
+
}
|
|
2286
|
+
if (options.waitForStabilityInitialDelay !== undefined) {
|
|
2287
|
+
NavTracker.instance.waitForStabilityInitialDelay = options.waitForStabilityInitialDelay;
|
|
2288
|
+
}
|
|
2289
|
+
if (options.waitForStabilityGlobalTimeout !== undefined) {
|
|
2290
|
+
NavTracker.instance.waitForStabilityGlobalTimeout = options.waitForStabilityGlobalTimeout;
|
|
2291
|
+
}
|
|
2292
|
+
if (options.pollMs !== undefined) {
|
|
2293
|
+
NavTracker.instance.pollMs = options.pollMs;
|
|
2294
|
+
}
|
|
2295
|
+
if (options.maxInflight !== undefined) {
|
|
2296
|
+
NavTracker.instance.maxInflight = options.maxInflight;
|
|
2297
|
+
}
|
|
2298
|
+
if (options.inflightGraceMs !== undefined) {
|
|
2299
|
+
NavTracker.instance.inflightGraceMs = options.inflightGraceMs;
|
|
2300
|
+
}
|
|
2301
|
+
if (options.waitForStabilityVerbose !== undefined) {
|
|
2302
|
+
NavTracker.instance.waitForStabilityVerbose = options.waitForStabilityVerbose;
|
|
2303
|
+
}
|
|
2304
|
+
proboLogger.debug(`NavTracker: updated existing instance with new values: quietTimeout=${NavTracker.instance.waitForStabilityQuietTimeout}, initialDelay=${NavTracker.instance.waitForStabilityInitialDelay}, globalTimeout=${NavTracker.instance.waitForStabilityGlobalTimeout}, verbose=${NavTracker.instance.waitForStabilityVerbose}`);
|
|
2305
|
+
}
|
|
2306
|
+
if (options === null || options === void 0 ? void 0 : options.waitForStabilityVerbose) {
|
|
2307
|
+
proboLogger.debug(`NavTracker: returning existing singleton instance`);
|
|
2308
|
+
}
|
|
1852
2309
|
}
|
|
1853
2310
|
return NavTracker.instance;
|
|
1854
2311
|
}
|
|
2312
|
+
/**
|
|
2313
|
+
* Reset the singleton instance (useful for testing or page changes)
|
|
2314
|
+
*/
|
|
2315
|
+
static resetInstance() {
|
|
2316
|
+
if (NavTracker.instance) {
|
|
2317
|
+
NavTracker.instance.stop();
|
|
2318
|
+
NavTracker.instance = null;
|
|
2319
|
+
proboLogger.debug(`NavTracker: reset singleton instance`);
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
1855
2322
|
}
|
|
1856
2323
|
NavTracker.instance = null;
|
|
1857
2324
|
|
|
@@ -1864,7 +2331,7 @@ const retryOptions = {
|
|
|
1864
2331
|
}
|
|
1865
2332
|
};
|
|
1866
2333
|
class Probo {
|
|
1867
|
-
constructor({ scenarioName, token = '', apiUrl = '', enableConsoleLogs = false, logToConsole = true, logToFile = false, debugLevel = ProboLogLevel.INFO, aiModel = AIModel.AZURE_GPT4_MINI, timeoutConfig =
|
|
2334
|
+
constructor({ scenarioName, token = '', apiUrl = '', enableConsoleLogs = false, logToConsole = true, logToFile = false, debugLevel = ProboLogLevel.INFO, aiModel = AIModel.AZURE_GPT4_MINI, timeoutConfig = {} }) {
|
|
1868
2335
|
// Configure logger transports and level
|
|
1869
2336
|
// configureLogger({ logToConsole, logToFile, level: debugLevel });
|
|
1870
2337
|
proboLogger.setLogLevel(debugLevel);
|
|
@@ -1883,7 +2350,7 @@ class Probo {
|
|
|
1883
2350
|
this.enableConsoleLogs = enableConsoleLogs;
|
|
1884
2351
|
this.scenarioName = scenarioName;
|
|
1885
2352
|
this.aiModel = aiModel;
|
|
1886
|
-
this.timeoutConfig = timeoutConfig;
|
|
2353
|
+
this.timeoutConfig = { ...DEFAULT_PLAYWRIGHT_TIMEOUT_CONFIG, ...timeoutConfig };
|
|
1887
2354
|
// set the log level for the api client
|
|
1888
2355
|
apiLogger.setLogLevel(debugLevel);
|
|
1889
2356
|
proboLogger.info(`Initializing: scenario=${scenarioName}, apiUrl=${apiEndPoint}, ` +
|
|
@@ -1902,8 +2369,13 @@ class Probo {
|
|
|
1902
2369
|
const aiModelToUse = options.aiModel !== undefined ? options.aiModel : this.aiModel;
|
|
1903
2370
|
proboLogger.log(`runStep: ${options.stepIdFromServer ? '#' + options.stepIdFromServer + ' - ' : ''}${stepPrompt}, aiModel: ${aiModelToUse}, pageUrl: ${page.url()}`);
|
|
1904
2371
|
this.setupConsoleLogs(page);
|
|
1905
|
-
|
|
1906
|
-
await
|
|
2372
|
+
// initialize the NavTracker
|
|
2373
|
+
await NavTracker.getInstance(page, {
|
|
2374
|
+
waitForStabilityQuietTimeout: this.timeoutConfig.waitForStabilityQuietTimeout,
|
|
2375
|
+
waitForStabilityInitialDelay: this.timeoutConfig.waitForStabilityInitialDelay,
|
|
2376
|
+
waitForStabilityGlobalTimeout: this.timeoutConfig.waitForStabilityGlobalTimeout,
|
|
2377
|
+
waitForStabilityVerbose: this.timeoutConfig.waitForStabilityVerbose
|
|
2378
|
+
});
|
|
1907
2379
|
// First check if the step exists in the database
|
|
1908
2380
|
let stepId;
|
|
1909
2381
|
if (options.useCache) {
|
|
@@ -2021,9 +2493,6 @@ class Probo {
|
|
|
2021
2493
|
}
|
|
2022
2494
|
async _handleStepCreation(page, stepPrompt, stepIdFromServer, useCache) {
|
|
2023
2495
|
proboLogger.debug(`Taking initial screenshot from the page ${page.url()}`);
|
|
2024
|
-
// not sure if this is needed
|
|
2025
|
-
// await handlePotentialNavigation(page);
|
|
2026
|
-
await waitForMutationsToSettle(page);
|
|
2027
2496
|
const { base_screenshot_url, base_html_content } = await pRetry(() => this.getInitialPageState(page), retryOptions);
|
|
2028
2497
|
return await this.apiClient.createStep({
|
|
2029
2498
|
stepIdFromServer,
|
|
@@ -2039,6 +2508,15 @@ class Probo {
|
|
|
2039
2508
|
setupBrowserConsoleLogs(page, this.enableConsoleLogs, proboLogger);
|
|
2040
2509
|
}
|
|
2041
2510
|
async getInitialPageState(page) {
|
|
2511
|
+
proboLogger.debug(`🔍 [getInitialPageState] timeoutConfig values: quietTimeout=${this.timeoutConfig.waitForStabilityQuietTimeout}, initialDelay=${this.timeoutConfig.waitForStabilityInitialDelay}, globalTimeout=${this.timeoutConfig.waitForStabilityGlobalTimeout}, verbose=${this.timeoutConfig.waitForStabilityVerbose}`);
|
|
2512
|
+
const navTracker = await NavTracker.getInstance(page, {
|
|
2513
|
+
waitForStabilityQuietTimeout: this.timeoutConfig.waitForStabilityQuietTimeout,
|
|
2514
|
+
waitForStabilityInitialDelay: this.timeoutConfig.waitForStabilityInitialDelay,
|
|
2515
|
+
waitForStabilityGlobalTimeout: this.timeoutConfig.waitForStabilityGlobalTimeout,
|
|
2516
|
+
waitForStabilityVerbose: this.timeoutConfig.waitForStabilityVerbose
|
|
2517
|
+
});
|
|
2518
|
+
proboLogger.debug(`🔍 getting initial page state. calling waitForNavigationToStabilize: ${this.timeoutConfig.waitForStabilityQuietTimeout}ms, waitForStabilityInitialDelay: ${this.timeoutConfig.waitForStabilityInitialDelay}ms, waitForStabilityGlobalTimeout: ${this.timeoutConfig.waitForStabilityGlobalTimeout}ms, waitForStabilityVerbose: ${this.timeoutConfig.waitForStabilityVerbose}`);
|
|
2519
|
+
await navTracker.waitForNavigationToStabilize();
|
|
2042
2520
|
const baseScreenshot = await this.screenshot(page);
|
|
2043
2521
|
proboLogger.debug(`🔍 baseScreenshot: ${baseScreenshot}`);
|
|
2044
2522
|
const baseHtmlContent = '';
|
|
@@ -2108,7 +2586,6 @@ class Probo {
|
|
|
2108
2586
|
highlightTimeout: 0
|
|
2109
2587
|
});
|
|
2110
2588
|
await this.unhighlightElements(page);
|
|
2111
|
-
await waitForMutationsToSettle(page);
|
|
2112
2589
|
const post_action_screenshot_url = await this.screenshot(page);
|
|
2113
2590
|
const post_html_content = '';
|
|
2114
2591
|
const executed_instruction = {
|
|
@@ -2130,12 +2607,16 @@ class Probo {
|
|
|
2130
2607
|
return executed_instruction;
|
|
2131
2608
|
}
|
|
2132
2609
|
async askAIHelper(page, question) {
|
|
2133
|
-
proboLogger.debug(`🔍 [askAI] Asking AI question: "${question}", scenarioName: ${this.scenarioName}, aiModel: ${this.aiModel}`);
|
|
2610
|
+
proboLogger.debug(`🔍 [askAI] Asking AI question: "${question}", scenarioName: ${this.scenarioName}, aiModel: ${this.aiModel} waitForStabilityQuietTimeout: ${this.timeoutConfig.waitForStabilityQuietTimeout}ms, waitForStabilityInitialDelay: ${this.timeoutConfig.waitForStabilityInitialDelay}ms, waitForStabilityGlobalTimeout: ${this.timeoutConfig.waitForStabilityGlobalTimeout}ms, waitForStabilityVerbose: ${this.timeoutConfig.waitForStabilityVerbose}`);
|
|
2134
2611
|
try {
|
|
2135
|
-
const navTracker = NavTracker.getInstance(page);
|
|
2136
|
-
await navTracker.waitForNavigationToStabilize();
|
|
2137
2612
|
// Get current page and capture screenshot
|
|
2138
|
-
await
|
|
2613
|
+
const navTracker = await NavTracker.getInstance(page, {
|
|
2614
|
+
waitForStabilityQuietTimeout: this.timeoutConfig.waitForStabilityQuietTimeout,
|
|
2615
|
+
waitForStabilityInitialDelay: this.timeoutConfig.waitForStabilityInitialDelay,
|
|
2616
|
+
waitForStabilityGlobalTimeout: this.timeoutConfig.waitForStabilityGlobalTimeout,
|
|
2617
|
+
waitForStabilityVerbose: this.timeoutConfig.waitForStabilityVerbose
|
|
2618
|
+
});
|
|
2619
|
+
await navTracker.waitForNavigationToStabilize();
|
|
2139
2620
|
const screenshot = await this.screenshot(page);
|
|
2140
2621
|
proboLogger.debug(`📸 [askAI] Screenshot captured: ${screenshot}`);
|
|
2141
2622
|
proboLogger.debug(`📤 [askAI] Sending chat request to backend`);
|
|
@@ -2152,5 +2633,5 @@ class Probo {
|
|
|
2152
2633
|
}
|
|
2153
2634
|
}
|
|
2154
2635
|
|
|
2155
|
-
export { Highlighter, NavTracker, PlaywrightAction, Probo, ProboLogLevel, ProboPlaywright, findClosestVisibleElement };
|
|
2636
|
+
export { Highlighter, NavTracker, OTP, PlaywrightAction, Probo, ProboLogLevel, ProboPlaywright, findClosestVisibleElement };
|
|
2156
2637
|
//# sourceMappingURL=index.js.map
|