@probolabs/playwright 1.2.1 → 1.4.0-rc.2

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/dist/index.js CHANGED
@@ -1,4 +1,12 @@
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 /**\n * Element tag constants for different types of interactive elements\n */\n const ElementTag = {\n CLICKABLE: \"CLICKABLE\",\n FILLABLE: \"FILLABLE\",\n SELECTABLE: \"SELECTABLE\",\n NON_INTERACTIVE_ELEMENT: 'NON_INTERACTIVE_ELEMENT',\n };\n\n var ApplyAIStatus;\n (function (ApplyAIStatus) {\n ApplyAIStatus[\"PREPARE_START\"] = \"PREPARE_START\";\n ApplyAIStatus[\"PREPARE_SUCCESS\"] = \"PREPARE_SUCCESS\";\n ApplyAIStatus[\"PREPARE_ERROR\"] = \"PREPARE_ERROR\";\n ApplyAIStatus[\"SEND_START\"] = \"SEND_START\";\n ApplyAIStatus[\"SEND_SUCCESS\"] = \"SEND_SUCCESS\";\n ApplyAIStatus[\"SEND_ERROR\"] = \"SEND_ERROR\";\n ApplyAIStatus[\"APPLY_AI_ERROR\"] = \"APPLY_AI_ERROR\";\n ApplyAIStatus[\"APPLY_AI_CANCELLED\"] = \"APPLY_AI_CANCELLED\";\n ApplyAIStatus[\"SUMMARY_COMPLETED\"] = \"SUMMARY_COMPLETED\";\n ApplyAIStatus[\"SUMMARY_ERROR\"] = \"SUMMARY_ERROR\";\n })(ApplyAIStatus || (ApplyAIStatus = {}));\n var ReplayStatus;\n (function (ReplayStatus) {\n ReplayStatus[\"REPLAY_START\"] = \"REPLAY_START\";\n ReplayStatus[\"REPLAY_SUCCESS\"] = \"REPLAY_SUCCESS\";\n ReplayStatus[\"REPLAY_ERROR\"] = \"REPLAY_ERROR\";\n ReplayStatus[\"REPLAY_CANCELLED\"] = \"REPLAY_CANCELLED\";\n })(ReplayStatus || (ReplayStatus = {}));\n\n // Action constants\n var PlaywrightAction;\n (function (PlaywrightAction) {\n PlaywrightAction[\"VISIT_BASE_URL\"] = \"VISIT_BASE_URL\";\n PlaywrightAction[\"VISIT_URL\"] = \"VISIT_URL\";\n PlaywrightAction[\"CLICK\"] = \"CLICK\";\n PlaywrightAction[\"FILL_IN\"] = \"FILL_IN\";\n PlaywrightAction[\"SELECT_DROPDOWN\"] = \"SELECT_DROPDOWN\";\n PlaywrightAction[\"SELECT_MULTIPLE_DROPDOWN\"] = \"SELECT_MULTIPLE_DROPDOWN\";\n PlaywrightAction[\"CHECK_CHECKBOX\"] = \"CHECK_CHECKBOX\";\n PlaywrightAction[\"SELECT_RADIO\"] = \"SELECT_RADIO\";\n PlaywrightAction[\"TOGGLE_SWITCH\"] = \"TOGGLE_SWITCH\";\n PlaywrightAction[\"SET_SLIDER\"] = \"SET_SLIDER\";\n PlaywrightAction[\"TYPE_KEYS\"] = \"TYPE_KEYS\";\n PlaywrightAction[\"HOVER\"] = \"HOVER\";\n PlaywrightAction[\"ASSERT_EXACT_VALUE\"] = \"ASSERT_EXACT_VALUE\";\n PlaywrightAction[\"ASSERT_CONTAINS_VALUE\"] = \"ASSERT_CONTAINS_VALUE\";\n PlaywrightAction[\"ASSERT_URL\"] = \"ASSERT_URL\";\n PlaywrightAction[\"SCROLL_TO_ELEMENT\"] = \"SCROLL_TO_ELEMENT\";\n PlaywrightAction[\"EXTRACT_VALUE\"] = \"EXTRACT_VALUE\";\n PlaywrightAction[\"ASK_AI\"] = \"ASK_AI\";\n PlaywrightAction[\"EXECUTE_SCRIPT\"] = \"EXECUTE_SCRIPT\";\n PlaywrightAction[\"UPLOAD_FILES\"] = \"UPLOAD_FILES\";\n PlaywrightAction[\"WAIT_FOR\"] = \"WAIT_FOR\";\n PlaywrightAction[\"WAIT_FOR_OTP\"] = \"WAIT_FOR_OTP\";\n PlaywrightAction[\"GEN_TOTP\"] = \"GEN_TOTP\";\n })(PlaywrightAction || (PlaywrightAction = {}));\n\n /**\n * Checks if any string in the array matches the given regular expression pattern.\n *\n * @param array - Array of strings to test.\n * @param pattern - Regular expression to test against each string.\n * @returns true if at least one string matches the pattern, false otherwise.\n */\n function testArray(array, pattern) {\n return array.some(item => pattern.test(item));\n }\n /**\n * Determines if an element is clickable\n *\n * @param element The DOM element to check\n * @returns boolean indicating if the element is fillable\n */\n function isClickableElement(element) {\n if (!element)\n return false;\n let depth = 0;\n while (depth < 5 && element && element.nodeType === Node.ELEMENT_NODE) {\n if (isClickableElementHelper(element) === IsClickable.YES)\n return true;\n if (isClickableElementHelper(element) === IsClickable.NO)\n return false;\n // if maybe, continue searching up to 5 levels up the DOM tree\n element = element.parentNode;\n depth++;\n }\n return false;\n }\n // clickable element detection result\n var IsClickable;\n (function (IsClickable) {\n IsClickable[\"YES\"] = \"YES\";\n IsClickable[\"NO\"] = \"NO\";\n IsClickable[\"MAYBE\"] = \"MAYBE\";\n })(IsClickable || (IsClickable = {}));\n function isClickableElementHelper(element) {\n var _a, _b;\n if (!element)\n return IsClickable.NO;\n //check for tag name\n const tagName = element.tagName.toLowerCase();\n const clickableTags = [\n 'a', 'button',\n ];\n if (clickableTags.includes(tagName))\n return IsClickable.YES;\n //check for clickable <input>\n const inputType = (_a = element.type) === null || _a === void 0 ? void 0 : _a.toLowerCase();\n const clickableTypes = [\n 'button', 'submit', 'reset', 'checkbox', 'radio',\n ];\n const ariaAutocompleteValues = [\n 'list', 'both',\n ];\n if (tagName === 'input') {\n if (clickableTypes.includes(inputType) || ariaAutocompleteValues.includes((_b = element.getAttribute('aria-autocomplete')) !== null && _b !== void 0 ? _b : ''))\n return IsClickable.YES;\n if (['date', 'number', 'range'].includes(inputType))\n return IsClickable.NO; //don't record the click as a change event will be generated for elements that generate an input change event\n }\n // check for cursor type\n const style = window.getComputedStyle(element);\n if (style.cursor === 'pointer')\n return IsClickable.YES;\n // check for attributes\n const clickableRoles = [\n 'button', 'combobox', 'listbox', 'dropdown', 'option', 'menu', 'menuitem',\n 'navigation', 'checkbox', 'switch', 'toggle', 'slider', 'textbox', 'listitem',\n 'presentation',\n ];\n const ariaPopupValues = [\n 'true', 'listbox', 'menu',\n ];\n if (element.hasAttribute('onclick') ||\n clickableRoles.includes(element.getAttribute('role') || '') ||\n ariaPopupValues.includes(element.getAttribute('aria-haspopup') || ''))\n return IsClickable.YES;\n // check for tabindex (means element is focusable and therefore clickable)\n if (parseInt(element.getAttribute('tabindex') || '-1') >= 0)\n return IsClickable.YES;\n // extract class names\n const classNames = Array.from(element.classList);\n // check for checkbox/radio-like class name - TODO: check if can be removed\n const checkboxPattern = /checkbox|switch|toggle|slider/i;\n if (testArray(classNames, checkboxPattern))\n return IsClickable.YES;\n // check for Material UI class names\n const muiClickableClassPattern = /MuiButton|MuiIconButton|MuiChip|MuiMenuItem|MuiListItem|MuiInputBase|MuiOutlinedInput|MuiSelect|MuiAutocomplete|MuiToggleButton|MuiBackdrop-root|MuiBackdrop-invisible/;\n if (testArray(classNames, muiClickableClassPattern))\n return IsClickable.YES;\n // check for SalesForce class names\n const sfClassPattern = /slds-button|slds-dropdown|slds-combobox|slds-picklist|slds-tabs|slds-pill|slds-action|slds-row-action|slds-context-bar|slds-input|slds-rich-text-area|slds-radio|slds-checkbox|slds-toggle|slds-link|slds-accordion|slds-tree/;\n if (testArray(classNames, sfClassPattern))\n return IsClickable.YES;\n // check for chart dots\n const chartClickableClassPattern = /recharts-dot/;\n if (testArray(classNames, chartClickableClassPattern))\n return IsClickable.YES;\n // check for React component classes\n const reactClickableClassPattern = /react-select|ant-select|rc-select|react-dropdown|react-autocomplete|react-datepicker|react-modal|react-tooltip|react-popover|react-menu|react-tabs|react-accordion|react-collapse|react-toggle|react-switch|react-checkbox|react-radio|react-button|react-link|react-card|react-list-item|react-menu-item|react-option|react-tab|react-panel|react-drawer|react-sidebar|react-nav|react-breadcrumb|react-pagination|react-stepper|react-wizard|react-carousel|react-slider|react-range|react-progress|react-badge|react-chip|react-tag|react-avatar|react-icon|react-fab|react-speed-dial|react-floating|react-sticky|react-affix|react-backdrop|react-overlay|react-portal|react-transition|react-animate|react-spring|react-framer|react-gesture|react-drag|react-drop|react-sortable|react-resizable|react-split|react-grid|react-table|react-datagrid|react-tree|react-treeview|react-file|react-upload|react-cropper|react-image|react-gallery|react-lightbox|react-player|react-video|react-audio|react-chart|react-graph|react-diagram|react-flow|react-d3|react-plotly|react-vega|react-vis|react-nivo|react-recharts|react-victory|react-echarts|react-highcharts|react-google-charts|react-fusioncharts|react-apexcharts|react-chartjs|react-chartkick|react-sparklines|react-trend|react-smooth|react-animated|react-lottie|react-spring|react-framer-motion|react-pose|react-motion|react-transition-group|react-router|react-navigation/i;\n if (testArray(classNames, reactClickableClassPattern))\n return IsClickable.YES;\n //check for cloudinary class names\n const cloudinaryClickableClassPattern = /cld-combobox|cld-upload-button|cld-controls|cld-player|cld-tab|cld-menu-item|cld-close|cld-play|cld-pause|cld-fullscreen|cld-browse|cld-cancel|cld-retry/;\n if (testArray(classNames, cloudinaryClickableClassPattern))\n return IsClickable.YES;\n return IsClickable.MAYBE;\n }\n function getParentNode(element) {\n if (!element || element.nodeType !== Node.ELEMENT_NODE)\n return null;\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 // 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 return parent;\n }\n function getElementDepth(element) {\n let depth = 0;\n let currentElement = element;\n while ((currentElement === null || currentElement === void 0 ? void 0 : currentElement.nodeType) === Node.ELEMENT_NODE) {\n depth++;\n currentElement = getParentNode(currentElement);\n }\n return depth;\n }\n\n // WebSocketsMessageType enum for WebSocket and event message types shared across the app\n var WebSocketsMessageType;\n (function (WebSocketsMessageType) {\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_PREPARE_START\"] = \"INTERACTION_APPLY_AI_PREPARE_START\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_PREPARE_SUCCESS\"] = \"INTERACTION_APPLY_AI_PREPARE_SUCCESS\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_PREPARE_ERROR\"] = \"INTERACTION_APPLY_AI_PREPARE_ERROR\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_SEND_TO_LLM_START\"] = \"INTERACTION_APPLY_AI_SEND_TO_LLM_START\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_SEND_TO_LLM_SUCCESS\"] = \"INTERACTION_APPLY_AI_SEND_TO_LLM_SUCCESS\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_SEND_TO_LLM_ERROR\"] = \"INTERACTION_APPLY_AI_SEND_TO_LLM_ERROR\";\n WebSocketsMessageType[\"INTERACTION_REPLAY_START\"] = \"INTERACTION_REPLAY_START\";\n WebSocketsMessageType[\"INTERACTION_REPLAY_SUCCESS\"] = \"INTERACTION_REPLAY_SUCCESS\";\n WebSocketsMessageType[\"INTERACTION_REPLAY_ERROR\"] = \"INTERACTION_REPLAY_ERROR\";\n WebSocketsMessageType[\"INTERACTION_REPLAY_CANCELLED\"] = \"INTERACTION_REPLAY_CANCELLED\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_CANCELLED\"] = \"INTERACTION_APPLY_AI_CANCELLED\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_ERROR\"] = \"INTERACTION_APPLY_AI_ERROR\";\n WebSocketsMessageType[\"INTERACTION_STEP_CREATED\"] = \"INTERACTION_STEP_CREATED\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_SUMMARY_COMPLETED\"] = \"INTERACTION_APPLY_AI_SUMMARY_COMPLETED\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_SUMMARY_ERROR\"] = \"INTERACTION_APPLY_AI_SUMMARY_ERROR\";\n WebSocketsMessageType[\"OTP_RETRIEVED\"] = \"OTP_RETRIEVED\";\n })(WebSocketsMessageType || (WebSocketsMessageType = {}));\n\n /**\n * Logging levels for Probo\n */\n var ProboLogLevel;\n (function (ProboLogLevel) {\n ProboLogLevel[\"DEBUG\"] = \"DEBUG\";\n ProboLogLevel[\"INFO\"] = \"INFO\";\n ProboLogLevel[\"LOG\"] = \"LOG\";\n ProboLogLevel[\"WARN\"] = \"WARN\";\n ProboLogLevel[\"ERROR\"] = \"ERROR\";\n })(ProboLogLevel || (ProboLogLevel = {}));\n const logLevelOrder = {\n [ProboLogLevel.DEBUG]: 0,\n [ProboLogLevel.INFO]: 1,\n [ProboLogLevel.LOG]: 2,\n [ProboLogLevel.WARN]: 3,\n [ProboLogLevel.ERROR]: 4,\n };\n class ProboLogger {\n constructor(prefix, level = ProboLogLevel.INFO) {\n this.prefix = prefix;\n this.level = level;\n }\n setLogLevel(level) {\n console.log(`[${this.prefix}] Setting log level to: ${level} (was: ${this.level})`);\n this.level = level;\n }\n shouldLog(level) {\n return logLevelOrder[level] >= logLevelOrder[this.level];\n }\n preamble(level) {\n const now = new Date();\n const hours = String(now.getHours()).padStart(2, '0');\n const minutes = String(now.getMinutes()).padStart(2, '0');\n const seconds = String(now.getSeconds()).padStart(2, '0');\n const milliseconds = String(now.getMilliseconds()).padStart(3, '0');\n return `[${hours}:${minutes}:${seconds}.${milliseconds}] [${this.prefix}] [${level}]`;\n }\n debug(...args) { if (this.shouldLog(ProboLogLevel.DEBUG))\n console.debug(this.preamble(ProboLogLevel.DEBUG), ...args); }\n info(...args) { if (this.shouldLog(ProboLogLevel.INFO))\n console.info(this.preamble(ProboLogLevel.INFO), ...args); }\n log(...args) { if (this.shouldLog(ProboLogLevel.LOG))\n console.log(this.preamble(ProboLogLevel.LOG), ...args); }\n warn(...args) { if (this.shouldLog(ProboLogLevel.WARN))\n console.warn(this.preamble(ProboLogLevel.WARN), ...args); }\n error(...args) { if (this.shouldLog(ProboLogLevel.ERROR))\n console.error(this.preamble(ProboLogLevel.ERROR), ...args); }\n }\n function doubleQuoteString(str) {\n if (!str)\n return '';\n return `\"${str.replace(/\"/g, '\\\\\"')}\"`;\n }\n\n new ProboLogger('apiclient');\n\n /**\n * Available AI models for LLM operations\n */\n var AIModel;\n (function (AIModel) {\n AIModel[\"AZURE_GPT4\"] = \"azure-gpt4\";\n AIModel[\"AZURE_GPT4_MINI\"] = \"azure-gpt4-mini\";\n AIModel[\"GEMINI_1_5_FLASH\"] = \"gemini-1.5-flash\";\n AIModel[\"GEMINI_2_5_FLASH\"] = \"gemini-2.5-flash\";\n AIModel[\"GPT4\"] = \"gpt4\";\n AIModel[\"GPT4_MINI\"] = \"gpt4-mini\";\n AIModel[\"CLAUDE_3_5\"] = \"claude-3.5\";\n AIModel[\"CLAUDE_SONNET_4_5\"] = \"claude-sonnet-4.5\";\n AIModel[\"CLAUDE_HAIKU_4_5\"] = \"claude-haiku-4.5\";\n AIModel[\"CLAUDE_OPUS_4_1\"] = \"claude-opus-4.1\";\n AIModel[\"GROK_2\"] = \"grok-2\";\n AIModel[\"LLAMA_4_SCOUT\"] = \"llama-4-scout\";\n AIModel[\"DEEPSEEK_V3\"] = \"deepseek-v3\";\n })(AIModel || (AIModel = {}));\n\n const highlighterLogger = new ProboLogger('highlighter');\n\n // Get text content of an element (recursively including child elements)\n function getElementText(element) {\n if (!element) return '';\n return element.textContent || '';\n }\n\n // Get placeholder text of an element\n function getElementPlaceholder(element) {\n if (!element) return '';\n return element.placeholder || '';\n }\n\n // Get CSS selector of an element (not recursively, just for a single element)\n function getElementCssSelector(element) {\n if (!(element instanceof Element)) return '';\n \n const tag = element.tagName.toLowerCase();\n let selector = tag;\n \n // Find nth-of-type for elements without ID\n let nth = 1;\n let sibling = element.previousElementSibling;\n while (sibling) {\n if (sibling.nodeName.toLowerCase() === tag) nth++;\n sibling = sibling.previousElementSibling;\n }\n if (nth > 1 || element.nextElementSibling) selector += `:nth-of-type(${nth})`;\n \n return selector;\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 // Helper function to check if element is a dropdown item\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 * Element Query Utilities\n */\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 highlighterLogger.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 * 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 robustQuerySelector(selector, root = document, all = false) {\n // First try to find in light DOM\n let elements = all ? root.querySelectorAll(selector) : root.querySelector(selector);\n if (elements) return elements;\n \n // Get all shadow roots\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 elements = robustQuerySelector(selector, el.shadowRoot, all);\n if (elements) return elements;\n }\n \n return all ? [] : null;\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 // unified path generation for xpath and css selector\n function extractElementPath(element) {\n if (!element) {\n highlighterLogger.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 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 = robustQuerySelector(`#${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 function filterNestedElements(nonZeroElements, parentCandidates, shouldKeepNestedElementFn) {\n /**\n * Filter elements based on parent/child relationships.\n * @param {Array} nonZeroElements - Elements sorted by depth (parents first)\n * @param {Set} parentCandidates - Set of all candidate parent CSS selectors (all nonZeroElements)\n * @param {Function} shouldKeepNestedElementFn - Function(element_info, parentPath, parentElementInfo) => boolean\n * @returns {Array} Filtered elements\n */\n return nonZeroElements.filter(element_info => {\n const parent = findClosestParent(parentCandidates, element_info);\n \n if (parent == null) {\n // No parent found, keep element\n return true;\n }\n \n // Find parent element_info\n const parentElementInfo = nonZeroElements.find(e => e.css_selector === parent);\n \n // Use custom function to determine if we should keep nested element\n return shouldKeepNestedElementFn(element_info, parent, parentElementInfo);\n });\n }\n\n function getDirectHandlers(element) {\n /**\n * Get direct handlers for an element (no bubbling check).\n * Returns array of handler objects.\n */\n const handlers = [];\n \n // Check onclick\n if (element.hasAttribute('onclick') || element.onclick) {\n handlers.push({\n type: 'onclick',\n source: element.hasAttribute('onclick') ? 'attribute' : 'property',\n value: element.getAttribute('onclick') || 'function'\n });\n }\n \n // Check React Fiber\n const reactKey = Object.keys(element).find(key => \n key.startsWith('__reactFiber') || key.startsWith('__reactInternalInstance')\n );\n if (reactKey) {\n const fiber = element[reactKey];\n if (fiber?.memoizedProps?.onClick || fiber?.pendingProps?.onClick) {\n handlers.push({\n type: 'react-onClick',\n source: 'react-fiber',\n hasMemoized: !!fiber?.memoizedProps?.onClick,\n hasPending: !!fiber?.pendingProps?.onClick\n });\n }\n }\n \n return handlers;\n }\n\n function getElementHandlersWithBubbling(element, depth = 0) {\n /**\n * Get handler info including bubbling (recursive DOM traversal).\n * Uses actual DOM relationships (parentElement) instead of CSS selector parsing.\n * Cursor uses getComputedStyle() which already includes inheritance.\n * @param {HTMLElement} element - The element to check\n * @param {number} depth - Recursion depth (safety limit)\n */\n // Safety check: null/undefined element\n if (!element) {\n return { handlers: [], cursorPointer: false, handlerSource: 'none' };\n }\n \n // Safety check: prevent infinite recursion (circular parent references)\n if (depth > 100) {\n throw new Error(`Recursion depth limit exceeded (${depth}) in getElementHandlersWithBubbling. Possible circular parent reference. Element: ${element.tagName || 'unknown'}`);\n }\n \n const directHandlers = getDirectHandlers(element);\n \n // If element has direct handlers, return them\n if (directHandlers.length > 0) {\n // Cursor: getComputedStyle already includes inheritance!\n let cursorPointer = false;\n try {\n const style = window.getComputedStyle(element);\n cursorPointer = style.cursor === 'pointer';\n } catch (e) {\n // getComputedStyle can fail in edge cases (e.g., detached elements)\n // Default to false\n }\n \n return {\n handlers: directHandlers,\n cursorPointer: cursorPointer,\n handlerSource: 'direct'\n };\n }\n \n // No direct handlers - check parent for bubbled handlers (using DOM, not CSS selectors)\n if (element.parentElement) {\n const parentResult = getElementHandlersWithBubbling(element.parentElement, depth + 1);\n if (parentResult.handlers.length > 0) {\n // Parent has handlers that would bubble to this element\n // Cursor: getComputedStyle already includes inheritance!\n let cursorPointer = false;\n try {\n const style = window.getComputedStyle(element);\n cursorPointer = style.cursor === 'pointer';\n } catch (e) {\n // getComputedStyle can fail in edge cases (e.g., detached elements)\n // Default to false\n }\n \n return {\n handlers: parentResult.handlers.map(h => ({\n ...h,\n source: 'bubbled'\n })),\n cursorPointer: cursorPointer,\n handlerSource: 'bubbled'\n };\n }\n }\n \n // No handlers at all\n let cursorPointer = false;\n try {\n const style = window.getComputedStyle(element);\n cursorPointer = style.cursor === 'pointer';\n } catch (e) {\n // getComputedStyle can fail in edge cases (e.g., detached elements)\n // Default to false\n }\n \n return {\n handlers: [],\n cursorPointer: cursorPointer,\n handlerSource: 'none'\n };\n }\n\n function normalizeHandlers(handlers) {\n /**\n * Normalize handlers for comparison (ignore source field, deduplicate).\n * Returns sorted array of unique normalized handler objects.\n */\n const seen = new Set();\n const normalized = [];\n \n for (const h of handlers) {\n // Safety check: skip malformed handlers\n if (!h || !h.type) continue;\n \n const key = h.type === 'react-onClick' \n ? 'react-onClick' \n : `${h.type}:${h.value || 'function'}`;\n \n if (!seen.has(key)) {\n seen.add(key);\n normalized.push({\n type: h.type,\n value: h.type === 'react-onClick' ? undefined : (h.value || 'function')\n });\n }\n }\n \n return normalized.sort((a, b) => {\n if (a.type !== b.type) return a.type.localeCompare(b.type);\n return (a.value || '').localeCompare(b.value || '');\n });\n }\n\n function compareHandlersForUniquify(childHandlers, parentHandlers) {\n /**\n * Compare handlers between child and parent for uniquification.\n * Returns true if handlers are different (should keep child), false if same (can unify).\n */\n const childNormalized = normalizeHandlers(childHandlers.handlers);\n const parentNormalized = normalizeHandlers(parentHandlers.handlers);\n \n const handlersEqual = JSON.stringify(childNormalized) === JSON.stringify(parentNormalized);\n const cursorEqual = childHandlers.cursorPointer === parentHandlers.cursorPointer;\n \n // If handlers or cursor differ, keep child (don't unify)\n return !handlersEqual || !cursorEqual;\n }\n\n function uniquifyElements(elements) {\n highlighterLogger.debug(`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 highlighterLogger.debug('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 highlighterLogger.debug('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 highlighterLogger.debug('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.depth - b.depth);\n highlighterLogger.debug(`After dimension filtering: ${nonZeroElements.length} elements remain (${elements.length - nonZeroElements.length} removed)`);\n \n // Create parent candidate set with ALL nonZeroElements (before filtering)\n // This allows findClosestParent to find any element as a parent, even if it gets filtered out later\n const parentCandidates = new Set(nonZeroElements.map(e => e.css_selector));\n \n // Filter nested elements using handler comparison and standard logic\n const filteredByParent = filterNestedElements(nonZeroElements, parentCandidates, (element_info, parent, parentElementInfo) => {\n return shouldKeepNestedElement(element_info, parent, parentElementInfo);\n });\n\n highlighterLogger.debug(`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 highlighterLogger.debug(`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 highlighterLogger.debug(`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(parentCandidates, element_info) { \n // Split the CSS selector into segments\n const segments = element_info.css_selector.split(' > ');\n \n // Try increasingly shorter paths until we find one in the parentCandidates set\n for (let i = segments.length - 1; i > 0; i--) {\n const parentPath = segments.slice(0, i).join(' > ');\n if (parentCandidates.has(parentPath)) {\n return parentPath;\n }\n }\n\n return null;\n }\n\n function shouldKeepNestedElement(elementInfo, parentPath, parentElementInfo) {\n // Check special cases FIRST (before handler comparison)\n const parentSegments = parentPath.split(' > ');\n\n // If parent is a link, don't keep child\n const isParentLink = /^a(:nth-of-type\\(\\d+\\))?$/.test(parentSegments[parentSegments.length - 1]);\n if (isParentLink) {\n return false; \n }\n \n // If this is a checkbox/radio input inside a label, don't keep the input (keep label instead)\n if (elementInfo.tag === 'input' && \n (elementInfo.type === 'checkbox' || elementInfo.type === 'radio')) {\n const isParentLabel = /^label(:nth-of-type\\(\\d+\\))?$/.test(parentSegments[parentSegments.length - 1]);\n if (isParentLabel) {\n return false;\n }\n }\n \n // Now check if handlers differ (only if special cases don't apply)\n if (parentElementInfo) {\n const childHandlers = getElementHandlersWithBubbling(elementInfo.element);\n const parentHandlers = getElementHandlersWithBubbling(parentElementInfo.element);\n \n // If handlers differ, keep child (don't unify)\n if (compareHandlersForUniquify(childHandlers, parentHandlers)) {\n return true;\n }\n }\n \n return isFormControl(elementInfo) || isDropdownItem(elementInfo) || isTableCell(elementInfo);\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 highlighterLogger.debug('[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 * 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 highlighterLogger.debug('🔍 [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 highlighterLogger.debug('🔍 [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 highlighterLogger.debug('🔍 [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 highlighterLogger.debug('🔍 [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 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 function findElementsWithPointer() {\n const elements = [];\n const allElements = getAllElementsIncludingShadow('*');\n \n allElements.forEach(element => {\n const style = window.getComputedStyle(element);\n const hasPointer = style.cursor === 'pointer';\n \n if (hasPointer) {\n elements.push(element);\n }\n });\n \n highlighterLogger.debug(`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 highlighterLogger.debug('Found inputs:', inputs.length, inputs);\n elements.push(...inputs);\n \n const textareas = [...getAllElementsIncludingShadow('textarea')];\n highlighterLogger.debug('Found textareas:', textareas.length);\n elements.push(...textareas);\n \n const editables = [...getAllElementsIncludingShadow('[contenteditable=\"true\"]')];\n highlighterLogger.debug('Found editables:', editables.length);\n elements.push(...editables);\n\n return elements;\n }\n\n // maximum number of characters in a logical name\n const MAX_LOGICAL_NAME_LENGTH = 25;\n \n /**\n * Query elements by CSS selector with :has-text(\"...\") pseudo-selector\n * @param {string} selector - CSS selector with optional :has-text(\"...\") pseudo-selector\n * @param {Element|Document} context - Context element to query within (defaults to document)\n * @returns {Element[]} Array of matched elements\n * @example\n * // Find all buttons with text \"Submit\"\n * querySelectorAllByText('button:has-text(\"Submit\")')\n * \n * // Find element with specific role and text\n * querySelectorAllByText('[role=\"button\"]:has-text(\"Click me\")')\n * \n * // Complex selector with descendants\n * querySelectorAllByText('button:has-text(\"foo\") > div > ul')\n * \n * // Multiple components\n * querySelectorAllByText('.container > button:has-text(\"Submit\")')\n * \n * // Multiple text filters\n * querySelectorAllByText('button:has-text(\"foo\") > div:has-text(\"bar\") > span')\n */\n function querySelectorAllByText(selector, context = document) {\n \t// Local helper function to compare two strings by normalizing whitespace and ignoring case\n \t// mimic the behavior of Playwright's :has-text() selector modifier\n \tfunction pwHasText(str1, str2) {\n \t\tif (!str1 || !str2) return false;\n \t\t\n \t\t// Normalize whitespace by replacing multiple spaces with single space and trim\n \t\tconst normalize = (str) => cleanText(str.toLowerCase());\n \t\t\n \t\tconst normalized1 = normalize(str1);\n \t\tconst normalized2 = normalize(str2);\n \t\t\n \t\treturn normalized1.includes(normalized2);\n \t}\n \t\n \t// Check if selector contains :has-text() pseudo-selector\n \tif (!selector.includes(':has-text(')) {\n \t\t// No :has-text pseudo-selector, use regular querySelectorAll\n \t\treturn Array.from(robustQuerySelector(selector, context, true));\n \t}\n \t\n \t// Split selector by combinators while preserving them\n \t// Matches: >, +, ~, or space (descendant combinator)\n \tconst parts = [];\n \tlet currentPart = '';\n \tlet inBracket = false;\n \tlet inTextPseudo = false;\n \tlet inTextQuotes = false;\n \tlet i = 0;\n \t\n \twhile (i < selector.length) {\n \t\tconst char = selector[i];\n \t\t\n \t\t// Track :has-text(\"...\") to avoid splitting inside it\n \t\tif (selector.substring(i, i + 10) === ':has-text(') {\n \t\t\tinTextPseudo = true;\n \t\t\tinTextQuotes = false;\n \t\t}\n \t\tif (inTextPseudo && char === '\"' && !inTextQuotes) {\n \t\t\tinTextQuotes = true;\n \t\t} else if (inTextPseudo && char === '\"' && inTextQuotes) {\n \t\t\t// Check if this quote is escaped\n \t\t\tif (i > 0 && selector[i - 1] === '\\\\') ; else {\n \t\t\t\tinTextQuotes = false;\n \t\t\t}\n \t\t}\n \t\tif (inTextPseudo && char === ')' && !inTextQuotes) {\n \t\t\tinTextPseudo = false;\n \t\t\tcurrentPart += char;\n \t\t\ti++;\n \t\t\tcontinue;\n \t\t}\n \t\t\n \t\t// Track brackets to avoid splitting on combinators inside attribute selectors\n \t\tif (char === '[') inBracket = true;\n \t\tif (char === ']') inBracket = false;\n \t\t\n \t\t// Check for combinators (not inside brackets or :has-text())\n \t\tif (!inBracket && !inTextPseudo && (char === '>' || char === '+' || char === '~')) {\n \t\t\tif (currentPart.trim()) parts.push({ selector: currentPart.trim(), combinator: null });\n \t\t\tparts.push({ selector: null, combinator: char.trim() });\n \t\t\tcurrentPart = '';\n \t\t\ti++;\n \t\t\tcontinue;\n \t\t}\n \t\t\n \t\t// Check for space combinator (descendant)\n \t\tif (!inBracket && !inTextPseudo && char === ' ') {\n \t\t\t// Skip multiple spaces\n \t\t\twhile (i < selector.length && selector[i] === ' ') i++;\n \t\t\t// Check if next char is a combinator (>, +, ~)\n \t\t\tif (i < selector.length && !'><+~'.includes(selector[i])) {\n \t\t\t\tif (currentPart.trim()) {\n \t\t\t\t\tparts.push({ selector: currentPart.trim(), combinator: null });\n \t\t\t\t\tparts.push({ selector: null, combinator: ' ' });\n \t\t\t\t\tcurrentPart = '';\n \t\t\t\t}\n \t\t\t}\n \t\t\tcontinue;\n \t\t}\n \t\t\n \t\tcurrentPart += char;\n \t\ti++;\n \t}\n \tif (currentPart.trim()) parts.push({ selector: currentPart.trim(), combinator: null });\n \t\n \t// Filter out combinator-only entries and rebuild as selector+combinator pairs\n \tconst selectorParts = [];\n \tfor (let j = 0; j < parts.length; j++) {\n \t\tif (parts[j].selector) {\n \t\t\tconst combinator = (j + 1 < parts.length && parts[j + 1].combinator) ? parts[j + 1].combinator : null;\n \t\t\t// Extract text value if present\n \t\t\tconst textMatch = parts[j].selector.match(/:has-text\\(\"((?:[^\"\\\\]|\\\\.)*)\"\\)/);\n \t\t\tconst textValue = textMatch ? textMatch[1] : null;\n \t\t\tconst selectorWithoutText = textMatch ? parts[j].selector.replace(/:has-text\\(\"((?:[^\"\\\\]|\\\\.)*)\"\\)/, '') : parts[j].selector;\n \t\t\t\n \t\t\tselectorParts.push({ \n \t\t\t\tselector: parts[j].selector,\n \t\t\t\tselectorWithoutText,\n \t\t\t\ttextValue,\n \t\t\t\tcombinator \n \t\t\t});\n \t\t}\n \t}\n \t\n \t// Process selector parts sequentially, applying text filters as we go\n \tlet currentElements = [context];\n \t\n \tfor (let partIndex = 0; partIndex < selectorParts.length; partIndex++) {\n \t\tconst part = selectorParts[partIndex];\n \t\tconst nextElements = [];\n \t\t\n \t\tfor (const ctx of currentElements) {\n \t\t\tlet candidates;\n \t\t\t\n \t\t\tif (partIndex === 0 && ctx === context) {\n \t\t\t\t// First part, query from document/context\n \t\t\t\tconst querySelector = part.selectorWithoutText.trim() || '*';\n \t\t\t\tcandidates = Array.from(robustQuerySelector(querySelector, ctx, true));\n \t\t\t\thighlighterLogger.debug(`querySelectorAllByText: first part, querySelector=${querySelector}, candidates=`, candidates);\n \t\t\t} \n \t\t\telse {\n \t\t\t\t// Subsequent parts, use combinator\n \t\t\t\tconst prevPart = selectorParts[partIndex - 1];\n \t\t\t\tconst combinator = prevPart.combinator;\n \t\t\t\tconst querySelector = part.selectorWithoutText.trim() || '*';\n \t\t\t\t\n \t\t\t\tif (combinator === '>') {\n \t\t\t\t\t// Direct children\n \t\t\t\t\tcandidates = Array.from(ctx.children).filter(child => {\n \t\t\t\t\t\treturn child.matches(querySelector);\n \t\t\t\t\t});\n \t\t\t\t} else if (combinator === '+') {\n \t\t\t\t\t// Next sibling\n \t\t\t\t\tconst sibling = ctx.nextElementSibling;\n \t\t\t\t\tcandidates = (sibling && sibling.matches(querySelector)) ? [sibling] : [];\n \t\t\t\t} else if (combinator === '~') {\n \t\t\t\t\t// Following siblings\n \t\t\t\t\tcandidates = [];\n \t\t\t\t\tlet sibling = ctx.nextElementSibling;\n \t\t\t\t\twhile (sibling) {\n \t\t\t\t\t\tif (sibling.matches(querySelector)) {\n \t\t\t\t\t\t\tcandidates.push(sibling);\n \t\t\t\t\t\t}\n \t\t\t\t\t\tsibling = sibling.nextElementSibling;\n \t\t\t\t\t}\n \t\t\t\t} \n \t\t\t\telse {\n \t\t\t\t\t// Descendant (space)\n \t\t\t\t\tcandidates = Array.from(robustQuerySelector(querySelector, ctx, true));\n \t\t\t\t}\n \t\t\t}\n \t\t\t\n \t\t\t// Apply text filter if present\n \t\t\tif (part.textValue !== null) {\n \t\t\t\tcandidates = candidates.filter(el => pwHasText(getElementText(el), part.textValue));\n \t\t\t}\n \t\t\t\n \t\t\tnextElements.push(...candidates);\n \t\t}\n \t\t\n \t\tcurrentElements = nextElements;\n \t\t\n \t\t// If no matches, bail early\n \t\tif (currentElements.length === 0) {\n \t\t\treturn [];\n \t\t}\n \t}\n \t\n \treturn currentElements;\n }\n\n const cleanText = (text) => {\n \treturn text.replace(/\\s+/g, ' ').trim();\n };\n\n // Helper function to get name selector\n const getNameSelector = (element) => {\n \tconst text = cleanText(getElementText(element));\n \tconst title = element.getAttribute('title');\n \tconst name = element.getAttribute('name');\n \tconst ariaLabel = element.getAttribute('aria-label');\n \tif (!text && !title && !name && !ariaLabel) throw new Error('No name attribute provided');\n \tif (text) return `:has-text(${doubleQuoteString(text)})`;\n \tif (title) return `[title=${doubleQuoteString(title)}]`;\n \tif (ariaLabel) return `[aria-label=${doubleQuoteString(ariaLabel)}]`;\n \treturn `[name=${doubleQuoteString(name)}]`; \n };\n\n const getLogicalName = (element) => {\n \tconst text = cleanText(getElementText(element));\n \tconst title = element.getAttribute('title');\n \tconst name = element.getAttribute('name');\n \tconst ariaLabel = element.getAttribute('aria-label');\n \tconst role = element.getAttribute('role');\n \tconst logicalName = text || title || name || ariaLabel;\n \treturn {\n \t\thasLogicalName: logicalName && logicalName.length < MAX_LOGICAL_NAME_LENGTH && !role?.startsWith('row'),\n \t\tlogicalName: logicalName\n \t};\n };\n\n\n /**\n * Individual strategy functions\n */\n\n function tryRoleStrategy(element) { \n \tconst role = element.getAttribute('role');\n \tconst tag = element.tagName.toLowerCase();\n \tconst { hasLogicalName, logicalName } = getLogicalName(element);\n \tif (role && hasLogicalName) {\n \t\treturn { \n \t\t\tstrategy: 'role', \n \t\t\tselector: `[role=${doubleQuoteString(role)}]${getNameSelector(element)}`\n \t\t};\n \t}\n \t\n \t// Tag-based role (button, a, input, etc.) + name\n \tif (['button', 'a', 'input', 'textarea', 'select'].includes(tag) && hasLogicalName) {\n \t\treturn { \n \t\t\tstrategy: 'role', \n \t\t\tselector: `${tag}${getNameSelector(element)}`\n \t\t};\n \t}\n \t\n \treturn null;\n }\n\n function tryPlaceholderStrategy(element) {\n \tconst placeholder = element.getAttribute('placeholder');\n \tif (placeholder) {\n \t\treturn { \n \t\t\tstrategy: 'placeholder', \n \t\t\tselector: `[placeholder=${doubleQuoteString(placeholder)}]` \n \t\t};\n \t}\n \treturn null;\n }\n\n function tryLabelStrategy(element) {\n \tconst tag = element.tagName.toLowerCase();\n \tif (['input', 'textarea', 'select'].includes(tag)) {\n \t\tconst id = element.id;\n \t\tif (id) {\n \t\t\tconst labels = robustQuerySelector(`label[for=${doubleQuoteString(id)}]`, document, true);\n \t\t\tif (labels.length === 1) { //make sure id is unique\n \t\t\t\tconst labelText = cleanText(getElementText(labels[0]));\n \t\t\t\tif (labelText) {\n \t\t\t\t\tconst labelSelector = `label:has-text(${doubleQuoteString(labelText)})`;\n \t\t\t\t\ttry {\n \t\t\t\t\t\tconst els = querySelectorAllByText(labelSelector, document);\n \t\t\t\t\t\tif (els.length === 1) {\n \t\t\t\t\t\t\treturn { \n \t\t\t\t\t\t\t\tstrategy: 'label for',\n \t\t\t\t\t\t\t\tlabel_selector: labelSelector\n \t\t\t\t\t\t\t};\n \t\t\t\t\t\t}\n \t\t\t\t\t} catch (error) {\n \t\t\t\t\t\thighlighterLogger.error(`internal error: while querying label selector ${labelSelector} for element ${element.outerHTML}`, error);\n \t\t\t\t\t\tthrow error;\n \t\t\t\t\t}\n \t\t\t\t}\n \t\t\t}\n \t\t}\n \t}\n \treturn null;\n }\n\n function tryAriaLabelledByStrategy(element) {\n \tconst arialabeledBy = element.getAttribute('aria-labelledby');\n \tconst tag = element.tagName.toLowerCase();\n \t\n \tif (arialabeledBy) {\n \t\tconst labels = robustQuerySelector(`#${CSS.escape(arialabeledBy)}`, document, true);\n \t\tif (labels.length === 1) {\n \t\t\tconst labelText = cleanText(getElementText(labels[0]));\n \t\t\tif (labelText) { //verify the label text is unique\n \t\t\t\tconst labelSelector = `${tag}:has-text(${doubleQuoteString(labelText)})`;\n \t\t\t\ttry {\n \t\t\t\t\tconst els = querySelectorAllByText(labelSelector, document);\n \t\t\t\t\tif (els.length === 1) {\n \t\t\t\t\t\treturn { \n \t\t\t\t\t\t\tstrategy: 'label by', \n \t\t\t\t\t\t\tlabel_selector: labelSelector\n \t\t\t\t\t\t};\n \t\t\t\t\t}\n \t\t\t\t} catch (error) {\n \t\t\t\t\thighlighterLogger.error(`internal error: while querying aria-labelledby selector ${labelSelector} for element ${element.outerHTML}`, error);\n \t\t\t\t\tthrow error;\n \t\t\t\t}\n \t\t\t}\n \t\t}\n \t}\n \treturn null;\n }\n\n // Only use text strategy if element is clickable/interactive \n function tryNameStrategy(element) {\t\n \tconst { hasLogicalName } = getLogicalName(element);\n \tconst classes = Array.from(element.classList).map(cls => CSS.escape(cls));\n \t\n \t// Only use name strategy if element is clickable/interactive \n \tif (isClickableElement(element) && hasLogicalName && classes.length > 0) {\n \t\treturn { \n \t\t\tstrategy: 'name', \n \t\t\tselector: `.${classes.join('.')}${getNameSelector(element)}` \n \t\t\t// selector: `${element.tagName.toLowerCase()}${getNameSelector(element)}`\n \t\t};\n \t}\n \treturn null;\n }\n\n function tryDataTestIdStrategy(element) {\n \tconst dataTestId = element.getAttribute('data-testid');\n \tif (dataTestId) {\n \t\treturn { \n \t\t\tstrategy: 'data-testid',\t\t\t\n \t\t\tselector: `[data-testid=${doubleQuoteString(dataTestId)}]` \n \t\t};\n \t}\n \treturn null;\n }\n\n function tryIdStrategy(element) {\n \tconst id = element.getAttribute('id');\n \tif (id && !/:|\\s|\\\\/.test(id)) { //avoid using id if it contain unusual characters (react creates ids like this: \":r:42\")\n \t\treturn { \n \t\t\tstrategy: 'id', \n \t\t\tselector: `#${CSS.escape(id)}` \n \t\t};\n \t}\n \treturn null;\n }\n\n // Try all strategies on an element in order of precedence\n function attemptStrategiesOnElement(element) {\n \tconst strategies = [\n \t\ttryRoleStrategy, \t\t\n \t\ttryAriaLabelledByStrategy,\n \t\ttryNameStrategy,\n \t\ttryLabelStrategy,\n \t\ttryDataTestIdStrategy,\n \t\ttryPlaceholderStrategy,\n \t\ttryIdStrategy,\t\t\n \t\t// tryClassStrategy\n \t];\n \t\n \tfor (const strategy of strategies) {\n \t\tconst result = strategy(element); \n \t\tif (result) { \n \t\t\treturn result;\n \t\t}\n \t}\n \treturn null;\n }\n\n /**\n * Generate a smart selector for an element\n * @param {Element} element - The element to generate a locator for\n * @param {string} childPath - The child path to generate a locator for\n * @returns {Object} The selector for the element\n */\n function generateSmartSelector(element, childPath = '') {\n \t// Terminate recursion\n \tif (!element || element.nodeType !== Node.ELEMENT_NODE) {\n \t\treturn {\n \t\t\tstrategy: 'css',\n \t\t\tselector: childPath\n \t\t};\n \t}\n \t\n \t// Try strategies 1-6 on current element\n \tconst result = attemptStrategiesOnElement(element);\n \tconst is_label = result?.strategy === 'label for' || result?.strategy === 'label by';\n\n \tif (result) { // found a strategy\t\t\t\n \t\t// If we have a child path, concatenate parent strategy result with child CSS path \n \t\tlet extendedResult = result; \n \t\tif (childPath) {\n \t\t\tconst selector = is_label ? `${childPath}` : `${result?.selector} > ${childPath}`;\n \t\t\textendedResult = {\n \t\t\t\t...extendedResult,\n \t\t\t\tselector: selector,\n \t\t\t\tstrategy: `${extendedResult.strategy} (concatenated with child CSS path)`\n \t\t\t};\n \t\t} \t\t\n \t\t// check if the combined selector is unique and if not add the index to the strategy\n \t\tif (!is_label) { //label element is already checked for uniqueness\n \t\t\ttry {\n \t\t\t\tconst matchedElements = querySelectorAllByText(result?.selector); \n \t\t\t\thighlighterLogger.debug(`querySelectorAllByText(${result?.selector}) matched ${matchedElements.length} elements`);\n \t\t\t\thighlighterLogger.debug(matchedElements);\n \t\t\t\tif (matchedElements.length > 1) {\n \t\t\t\t\tconst index = matchedElements.findIndex(el => el === element);\n \t\t\t\t\tif (index === -1) {\n \t\t\t\t\t\thighlighterLogger.error('internal error: Element not found in matched elements', element, result?.selector, matchedElements);\n \t\t\t\t\t\treturn null;\n \t\t\t\t\t}\n \t\t\t\t\textendedResult = {\n \t\t\t\t\t\t...extendedResult,\n \t\t\t\t\t\tstrategy: `${extendedResult.strategy} (by index)`,\n \t\t\t\t\t\tindex,\n \t\t\t\t\t}; \n \t\t\t\t}\n \t\t\t} catch (error) {\n \t\t\t\thighlighterLogger.error(`internal error: while checking if selector ${result?.selector} is unique for element ${element.outerHTML}`, error);\n \t\t\t\tthrow error;\n \t\t\t}\t\t\t\n \t\t}\n \t\treturn extendedResult;\n \t}\n \t\n \t// Get CSS selector for current element and build child path \n \tlet newChildPath;\n \t// didn't find strategy\n \tconst elementSelector = getElementCssSelector(element);\n \tnewChildPath = childPath ? `${elementSelector} > ${childPath}` : elementSelector;\n \tconst numComponents = newChildPath.split(' > ').length;\n \tif (numComponents > 2) { //require at least 2 components for css strategy\n \t\t// check if the combined selector is unique\n \t\tconst matchedElements = robustQuerySelector(newChildPath, document, true);\n \t\tif (matchedElements.length === 1) {\n \t\t\treturn {\n \t\t\t\tstrategy: 'css',\t\t\t\t\n \t\t\t\tselector: newChildPath\n \t\t\t};\n \t\t}\n \t}\n\n \t// Recursively try parent element\n \treturn generateSmartSelector(getParentNode(element), newChildPath);\n }\n\n const findElementByInfo = (elementInfo, useSmartSelectors = false) => {\n \thighlighterLogger.debug('findElementByInfo:', elementInfo);\n \tif (useSmartSelectors) {\n \t return findElementBySelectors(elementInfo.smart_iframe_selector, elementInfo.smart_selector, true);\n \t}\n \treturn findElementBySelectors(elementInfo.iframe_selector, elementInfo.css_selector, false);\n };\n\n const compareSelectors = (selector1, selector2, useSmartSelectors = false) => {\n \tif (useSmartSelectors) {\n \t\treturn JSON.stringify(selector1) === JSON.stringify(selector2);\n \t}\n \treturn selector1 === selector2;\n };\n\n const findElementBySelectors = (iframeSelector, elementSelector, useSmartSelectors = false) => {\n \tlet element;\n \t\n \t// first find the iframe that contains the element\n \tif (iframeSelector) { \n \t const frames = getAllDocumentElementsIncludingShadow('iframe', document);\n \t \n \t // Iterate over all frames and compare their CSS selectors\n \t for (const frame of frames) {\n \t\tconst selector = useSmartSelectors ? generateSmartSelector(frame) : generateCssPath(frame);\n \t\tif (compareSelectors(selector, iframeSelector, useSmartSelectors)) {\n \t\t const frameDocument = frame.contentDocument || frame.contentWindow.document;\n \t\t element = useSmartSelectors ? findElementBySmartSelector(elementSelector, frameDocument) : robustQuerySelector(elementSelector, frameDocument);\t\t \n \t\t break;\n \t\t} \n \t }\t}\n \telse {\t\t\n \t\telement = useSmartSelectors ? findElementBySmartSelector(elementSelector, document) : robustQuerySelector(elementSelector, document);\t\t\n \t}\n \t \n \tif (element) {\n \t\thighlighterLogger.debug('findElementBySelectors: found element ', element);\n \t}\n \telse {\n \t highlighterLogger.warn('findElementBySelectors: failed to find element with CSS selector:', JSON.stringify(elementSelector));\n \t}\n \n \treturn element;\n };\n \n function findElementBySmartSelector(smartSelector, root = document) {\t\n \thighlighterLogger.debug(`findElementBySmartSelector: called with smartSelector=${JSON.stringify(smartSelector)}`);\n \tlet combinedSelector;\n \tconst mainStrategy = smartSelector.strategy.split(' ')[0]; //extract the main strategy from the full strategy string\n \tswitch(mainStrategy) {\n case 'role': \n case 'placeholder':\n case 'name':\n case 'data-testid':\n case 'id':\n case 'css':\n case 'class':\n \t\t highlighterLogger.debug(`findElementBySmartSelector: strategy='${smartSelector.strategy}', selector=${smartSelector?.selector}, index=${smartSelector?.index}`);\n if (typeof smartSelector?.index === 'number') {\n return querySelectorAllByText(smartSelector.selector, root)[smartSelector.index];\n }\n else {\n return querySelectorAllByText(smartSelector.selector, root)[0];\n }\t\t \n case 'label':\n \t\t\tif (smartSelector.strategy === 'label for') {\n \t\t\t\thighlighterLogger.debug(`findElementBySmartSelector: strategy='label for', label_selector=${smartSelector.label_selector}`);\n \t\t\t\tconst label_for = querySelectorAllByText(smartSelector.label_selector, root)[0];\n \t\t\t\tcombinedSelector = smartSelector?.selector ? `#${label_for.getAttribute('for')} > ${smartSelector.selector}` : `#${label_for.getAttribute('for')}`;\n \t\t\t\treturn robustQuerySelector(combinedSelector, root);\n \t\t\t}\n \t\t\telse if (smartSelector.strategy === 'label by') {\n \t\t\t\thighlighterLogger.debug(`findElementBySmartSelector: strategy='label by', label_selector=${smartSelector.label_selector}`);\n \t\t\t\tconst label_by = querySelectorAllByText(smartSelector.label_selector, root)[0];\n \t\t\t\tcombinedSelector = smartSelector?.selector ? `[aria-labelledby=\"${label_by.getAttribute('id')}\"] > ${smartSelector.selector}` : `[aria-labelledby=\"${label_by.getAttribute('id')}\"]`;\n \t\t\t\treturn robustQuerySelector(combinedSelector, root);\n \t\t\t}\n \t\t\telse {\n \t\t\t\thighlighterLogger.error(`findElementBySmartSelector: unsupported label strategy: ${smartSelector.strategy}`);\n \t\t\t\treturn null;\n \t\t\t} \n default:\n highlighterLogger.error(`findElementBySmartSelector: Unsupported smart selector strategy: ${smartSelector.strategy}`);\n return null;\n }\n }\n\n function getElementInfo(element, index) { \t\t\t\n \tconst iframe = getContainingIFrame(element); \n \tconst iframe_selector = iframe ? generateCssPath(iframe) : \"\";\t\n \tconst smart_iframe_selector = iframe ? generateSmartSelector(iframe) : null;\n \t\n \t// Return element info with pre-calculated values\n \treturn {\n \t\tindex: index ?? -1,\n \t\ttag: element.tagName.toLowerCase(),\n \t\ttype: element.type || '',\n \t\ttext: getElementText(element) || getElementPlaceholder(element),\n \t\thtml: cleanHTML(element.outerHTML),\n \t\txpath: generateXPath(element),\n \t\tcss_selector: generateCssPath(element),\n \t\tbounding_box: element.getBoundingClientRect(),\t\t\n \t\tiframe_selector: iframe_selector,\t\t\n \t\tsmart_selector: generateSmartSelector(element),\n \t\tsmart_iframe_selector: smart_iframe_selector,\n \t\telement: element,\n \t\tdepth: getElementDepth(element)\n \t};\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 highlighterLogger.debug('✅ 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 highlighterLogger.debug('✅ 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 highlighterLogger.debug('⚠️ 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 highlighterLogger.debug('📏 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 highlighterLogger.debug('✅ 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 highlighterLogger.error('❌ Missing required properties in actualInteractionElementInfo');\n return null;\n }\n\n highlighterLogger.debug('🔍 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 highlighterLogger.debug('❌ 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 highlighterLogger.debug('❌ 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 highlighterLogger.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, false,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 highlighterLogger.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 async function findElements(elementTypes, verbose=true) {\n const typesArray = Array.isArray(elementTypes) ? elementTypes : [elementTypes];\n highlighterLogger.debug('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 highlighterLogger.debug(`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 highlighterLogger.debug(`Out of which ${visibleElements.length} elements are visible:`);\n if (verbose) {\n visibleElements.forEach(info => {\n highlighterLogger.debug(`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, enableSmartSelectors=false, 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;\n if (!element) {\n element = findElementByInfo(elementInfo, enableSmartSelectors);\n if (!element) {\n highlighterLogger.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 highlighterLogger.debug(\"[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 highlighterLogger.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.toString();\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 highlighterLogger.debug('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(iframeSelector, elementSelector, isHover=false, useSmartSelectors=false) {\n // console.log('[ProboLabs] findAndCacheActualElement called with:', cssSelector, iframeSelector);\n highlighterLogger.debug(`[ProboLabs] findAndCacheActualElement called with: iframeSelector=${iframeSelector}, elementSelector=${elementSelector}, useSmartSelectors=${useSmartSelectors}`);\n let el = findElementBySelectors(iframeSelector, elementSelector, useSmartSelectors);\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 highlighterLogger.debug(`[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 highlighterLogger.debug('[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 window.ProboLabs.setLoggerDebugLevel = function(debugLevel) {\n highlighterLogger.setLogLevel(debugLevel);\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.deserializeNodeFromJSON = deserializeNodeFromJSON;\n exports.detectScrollableContainers = detectScrollableContainers;\n exports.findElementByInfo = findElementByInfo;\n exports.findElementBySelectors = findElementBySelectors;\n exports.findElements = findElements;\n exports.generateCssPath = generateCssPath;\n exports.generateSmartSelector = generateSmartSelector;\n exports.getAriaLabelledByText = getAriaLabelledByText;\n exports.getContainingIFrame = getContainingIFrame;\n exports.getElementInfo = getElementInfo;\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 /**\n * Element tag constants for different types of interactive elements\n */\n const ElementTag = {\n CLICKABLE: \"CLICKABLE\",\n FILLABLE: \"FILLABLE\",\n SELECTABLE: \"SELECTABLE\",\n NON_INTERACTIVE_ELEMENT: 'NON_INTERACTIVE_ELEMENT',\n };\n\n var ApplyAIStatus;\n (function (ApplyAIStatus) {\n ApplyAIStatus[\"PREPARE_START\"] = \"PREPARE_START\";\n ApplyAIStatus[\"PREPARE_SUCCESS\"] = \"PREPARE_SUCCESS\";\n ApplyAIStatus[\"PREPARE_ERROR\"] = \"PREPARE_ERROR\";\n ApplyAIStatus[\"SEND_START\"] = \"SEND_START\";\n ApplyAIStatus[\"SEND_SUCCESS\"] = \"SEND_SUCCESS\";\n ApplyAIStatus[\"SEND_ERROR\"] = \"SEND_ERROR\";\n ApplyAIStatus[\"APPLY_AI_ERROR\"] = \"APPLY_AI_ERROR\";\n ApplyAIStatus[\"APPLY_AI_CANCELLED\"] = \"APPLY_AI_CANCELLED\";\n ApplyAIStatus[\"SUMMARY_COMPLETED\"] = \"SUMMARY_COMPLETED\";\n ApplyAIStatus[\"SUMMARY_ERROR\"] = \"SUMMARY_ERROR\";\n })(ApplyAIStatus || (ApplyAIStatus = {}));\n var ReplayStatus;\n (function (ReplayStatus) {\n ReplayStatus[\"REPLAY_START\"] = \"REPLAY_START\";\n ReplayStatus[\"REPLAY_SUCCESS\"] = \"REPLAY_SUCCESS\";\n ReplayStatus[\"REPLAY_ERROR\"] = \"REPLAY_ERROR\";\n ReplayStatus[\"REPLAY_CANCELLED\"] = \"REPLAY_CANCELLED\";\n })(ReplayStatus || (ReplayStatus = {}));\n\n // Action constants\n var PlaywrightAction;\n (function (PlaywrightAction) {\n PlaywrightAction[\"VISIT_BASE_URL\"] = \"VISIT_BASE_URL\";\n PlaywrightAction[\"VISIT_URL\"] = \"VISIT_URL\";\n PlaywrightAction[\"CLICK\"] = \"CLICK\";\n PlaywrightAction[\"FILL_IN\"] = \"FILL_IN\";\n PlaywrightAction[\"SELECT_DROPDOWN\"] = \"SELECT_DROPDOWN\";\n PlaywrightAction[\"SELECT_MULTIPLE_DROPDOWN\"] = \"SELECT_MULTIPLE_DROPDOWN\";\n PlaywrightAction[\"CHECK_CHECKBOX\"] = \"CHECK_CHECKBOX\";\n PlaywrightAction[\"SELECT_RADIO\"] = \"SELECT_RADIO\";\n PlaywrightAction[\"TOGGLE_SWITCH\"] = \"TOGGLE_SWITCH\";\n PlaywrightAction[\"SET_SLIDER\"] = \"SET_SLIDER\";\n PlaywrightAction[\"TYPE_KEYS\"] = \"TYPE_KEYS\";\n PlaywrightAction[\"HOVER\"] = \"HOVER\";\n PlaywrightAction[\"ASSERT_EXACT_VALUE\"] = \"ASSERT_EXACT_VALUE\";\n PlaywrightAction[\"ASSERT_CONTAINS_VALUE\"] = \"ASSERT_CONTAINS_VALUE\";\n PlaywrightAction[\"ASSERT_URL\"] = \"ASSERT_URL\";\n PlaywrightAction[\"SCROLL_TO_ELEMENT\"] = \"SCROLL_TO_ELEMENT\";\n PlaywrightAction[\"EXTRACT_VALUE\"] = \"EXTRACT_VALUE\";\n PlaywrightAction[\"ASK_AI\"] = \"ASK_AI\";\n PlaywrightAction[\"EXECUTE_SCRIPT\"] = \"EXECUTE_SCRIPT\";\n PlaywrightAction[\"UPLOAD_FILES\"] = \"UPLOAD_FILES\";\n PlaywrightAction[\"WAIT_FOR\"] = \"WAIT_FOR\";\n PlaywrightAction[\"WAIT_FOR_OTP\"] = \"WAIT_FOR_OTP\";\n PlaywrightAction[\"GEN_TOTP\"] = \"GEN_TOTP\";\n // Sequence marker actions (not real interactions, used only for grouping)\n PlaywrightAction[\"SEQUENCE_START\"] = \"SEQUENCE_START\";\n PlaywrightAction[\"SEQUENCE_END\"] = \"SEQUENCE_END\";\n })(PlaywrightAction || (PlaywrightAction = {}));\n\n /**\n * Checks if any string in the array matches the given regular expression pattern.\n *\n * @param array - Array of strings to test.\n * @param pattern - Regular expression to test against each string.\n * @returns true if at least one string matches the pattern, false otherwise.\n */\n function testArray(array, pattern) {\n return array.some(item => pattern.test(item));\n }\n /**\n * Determines if an element is clickable\n *\n * @param element The DOM element to check\n * @returns boolean indicating if the element is fillable\n */\n function isClickableElement(element) {\n if (!element)\n return false;\n let depth = 0;\n while (depth < 5 && element && element.nodeType === Node.ELEMENT_NODE) {\n if (isClickableElementHelper(element) === IsClickable.YES)\n return true;\n if (isClickableElementHelper(element) === IsClickable.NO)\n return false;\n // if maybe, continue searching up to 5 levels up the DOM tree\n element = element.parentNode;\n depth++;\n }\n return false;\n }\n // clickable element detection result\n var IsClickable;\n (function (IsClickable) {\n IsClickable[\"YES\"] = \"YES\";\n IsClickable[\"NO\"] = \"NO\";\n IsClickable[\"MAYBE\"] = \"MAYBE\";\n })(IsClickable || (IsClickable = {}));\n function isClickableElementHelper(element) {\n var _a, _b;\n if (!element)\n return IsClickable.NO;\n //check for tag name\n const tagName = element.tagName.toLowerCase();\n const clickableTags = [\n 'a', 'button',\n ];\n if (clickableTags.includes(tagName))\n return IsClickable.YES;\n //check for clickable <input>\n const inputType = (_a = element.type) === null || _a === void 0 ? void 0 : _a.toLowerCase();\n const clickableTypes = [\n 'button', 'submit', 'reset', 'checkbox', 'radio',\n ];\n const ariaAutocompleteValues = [\n 'list', 'both',\n ];\n if (tagName === 'input') {\n if (clickableTypes.includes(inputType) || ariaAutocompleteValues.includes((_b = element.getAttribute('aria-autocomplete')) !== null && _b !== void 0 ? _b : ''))\n return IsClickable.YES;\n if (['date', 'number', 'range'].includes(inputType))\n return IsClickable.NO; //don't record the click as a change event will be generated for elements that generate an input change event\n }\n // check for cursor type\n const style = window.getComputedStyle(element);\n if (style.cursor === 'pointer')\n return IsClickable.YES;\n // check for attributes\n const clickableRoles = [\n 'button', 'combobox', 'listbox', 'dropdown', 'option', 'menu', 'menuitem',\n 'navigation', 'checkbox', 'switch', 'toggle', 'slider', 'textbox', 'listitem',\n 'presentation',\n ];\n const ariaPopupValues = [\n 'true', 'listbox', 'menu',\n ];\n if (element.hasAttribute('onclick') ||\n clickableRoles.includes(element.getAttribute('role') || '') ||\n ariaPopupValues.includes(element.getAttribute('aria-haspopup') || ''))\n return IsClickable.YES;\n // check for tabindex (means element is focusable and therefore clickable)\n if (parseInt(element.getAttribute('tabindex') || '-1') >= 0)\n return IsClickable.YES;\n // extract class names\n const classNames = Array.from(element.classList);\n // check for checkbox/radio-like class name - TODO: check if can be removed\n const checkboxPattern = /checkbox|switch|toggle|slider/i;\n if (testArray(classNames, checkboxPattern))\n return IsClickable.YES;\n // check for Material UI class names\n const muiClickableClassPattern = /MuiButton|MuiIconButton|MuiChip|MuiMenuItem|MuiListItem|MuiInputBase|MuiOutlinedInput|MuiSelect|MuiAutocomplete|MuiToggleButton|MuiBackdrop-root|MuiBackdrop-invisible/;\n if (testArray(classNames, muiClickableClassPattern))\n return IsClickable.YES;\n // check for SalesForce class names\n const sfClassPattern = /slds-button|slds-dropdown|slds-combobox|slds-picklist|slds-tabs|slds-pill|slds-action|slds-row-action|slds-context-bar|slds-input|slds-rich-text-area|slds-radio|slds-checkbox|slds-toggle|slds-link|slds-accordion|slds-tree/;\n if (testArray(classNames, sfClassPattern))\n return IsClickable.YES;\n // check for chart dots\n const chartClickableClassPattern = /recharts-dot/;\n if (testArray(classNames, chartClickableClassPattern))\n return IsClickable.YES;\n // check for React component classes\n const reactClickableClassPattern = /react-select|ant-select|rc-select|react-dropdown|react-autocomplete|react-datepicker|react-modal|react-tooltip|react-popover|react-menu|react-tabs|react-accordion|react-collapse|react-toggle|react-switch|react-checkbox|react-radio|react-button|react-link|react-card|react-list-item|react-menu-item|react-option|react-tab|react-panel|react-drawer|react-sidebar|react-nav|react-breadcrumb|react-pagination|react-stepper|react-wizard|react-carousel|react-slider|react-range|react-progress|react-badge|react-chip|react-tag|react-avatar|react-icon|react-fab|react-speed-dial|react-floating|react-sticky|react-affix|react-backdrop|react-overlay|react-portal|react-transition|react-animate|react-spring|react-framer|react-gesture|react-drag|react-drop|react-sortable|react-resizable|react-split|react-grid|react-table|react-datagrid|react-tree|react-treeview|react-file|react-upload|react-cropper|react-image|react-gallery|react-lightbox|react-player|react-video|react-audio|react-chart|react-graph|react-diagram|react-flow|react-d3|react-plotly|react-vega|react-vis|react-nivo|react-recharts|react-victory|react-echarts|react-highcharts|react-google-charts|react-fusioncharts|react-apexcharts|react-chartjs|react-chartkick|react-sparklines|react-trend|react-smooth|react-animated|react-lottie|react-spring|react-framer-motion|react-pose|react-motion|react-transition-group|react-router|react-navigation/i;\n if (testArray(classNames, reactClickableClassPattern))\n return IsClickable.YES;\n //check for cloudinary class names\n const cloudinaryClickableClassPattern = /cld-combobox|cld-upload-button|cld-controls|cld-player|cld-tab|cld-menu-item|cld-close|cld-play|cld-pause|cld-fullscreen|cld-browse|cld-cancel|cld-retry/;\n if (testArray(classNames, cloudinaryClickableClassPattern))\n return IsClickable.YES;\n return IsClickable.MAYBE;\n }\n function getParentNode(element) {\n if (!element || element.nodeType !== Node.ELEMENT_NODE)\n return null;\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 // 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 return parent;\n }\n function getElementDepth(element) {\n let depth = 0;\n let currentElement = element;\n while ((currentElement === null || currentElement === void 0 ? void 0 : currentElement.nodeType) === Node.ELEMENT_NODE) {\n depth++;\n currentElement = getParentNode(currentElement);\n }\n return depth;\n }\n\n // WebSocketsMessageType enum for WebSocket and event message types shared across the app\n var WebSocketsMessageType;\n (function (WebSocketsMessageType) {\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_PREPARE_START\"] = \"INTERACTION_APPLY_AI_PREPARE_START\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_PREPARE_SUCCESS\"] = \"INTERACTION_APPLY_AI_PREPARE_SUCCESS\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_PREPARE_ERROR\"] = \"INTERACTION_APPLY_AI_PREPARE_ERROR\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_SEND_TO_LLM_START\"] = \"INTERACTION_APPLY_AI_SEND_TO_LLM_START\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_SEND_TO_LLM_SUCCESS\"] = \"INTERACTION_APPLY_AI_SEND_TO_LLM_SUCCESS\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_SEND_TO_LLM_ERROR\"] = \"INTERACTION_APPLY_AI_SEND_TO_LLM_ERROR\";\n WebSocketsMessageType[\"INTERACTION_REPLAY_START\"] = \"INTERACTION_REPLAY_START\";\n WebSocketsMessageType[\"INTERACTION_REPLAY_SUCCESS\"] = \"INTERACTION_REPLAY_SUCCESS\";\n WebSocketsMessageType[\"INTERACTION_REPLAY_ERROR\"] = \"INTERACTION_REPLAY_ERROR\";\n WebSocketsMessageType[\"INTERACTION_REPLAY_CANCELLED\"] = \"INTERACTION_REPLAY_CANCELLED\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_CANCELLED\"] = \"INTERACTION_APPLY_AI_CANCELLED\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_ERROR\"] = \"INTERACTION_APPLY_AI_ERROR\";\n WebSocketsMessageType[\"INTERACTION_STEP_CREATED\"] = \"INTERACTION_STEP_CREATED\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_SUMMARY_COMPLETED\"] = \"INTERACTION_APPLY_AI_SUMMARY_COMPLETED\";\n WebSocketsMessageType[\"INTERACTION_APPLY_AI_SUMMARY_ERROR\"] = \"INTERACTION_APPLY_AI_SUMMARY_ERROR\";\n WebSocketsMessageType[\"OTP_RETRIEVED\"] = \"OTP_RETRIEVED\";\n WebSocketsMessageType[\"TEST_SUITE_RUN_START\"] = \"TEST_SUITE_RUN_START\";\n WebSocketsMessageType[\"TEST_SUITE_RUN_LOG\"] = \"TEST_SUITE_RUN_LOG\";\n WebSocketsMessageType[\"TEST_SUITE_RUN_COMPLETE\"] = \"TEST_SUITE_RUN_COMPLETE\";\n WebSocketsMessageType[\"TEST_SUITE_RUN_ERROR\"] = \"TEST_SUITE_RUN_ERROR\";\n WebSocketsMessageType[\"TEST_SUITE_RUN_REPORTER_EVENT\"] = \"TEST_SUITE_RUN_REPORTER_EVENT\";\n })(WebSocketsMessageType || (WebSocketsMessageType = {}));\n\n /**\n * Logging levels for Probo\n */\n var ProboLogLevel;\n (function (ProboLogLevel) {\n ProboLogLevel[\"DEBUG\"] = \"DEBUG\";\n ProboLogLevel[\"INFO\"] = \"INFO\";\n ProboLogLevel[\"LOG\"] = \"LOG\";\n ProboLogLevel[\"WARN\"] = \"WARN\";\n ProboLogLevel[\"ERROR\"] = \"ERROR\";\n })(ProboLogLevel || (ProboLogLevel = {}));\n const logLevelOrder = {\n [ProboLogLevel.DEBUG]: 0,\n [ProboLogLevel.INFO]: 1,\n [ProboLogLevel.LOG]: 2,\n [ProboLogLevel.WARN]: 3,\n [ProboLogLevel.ERROR]: 4,\n };\n class ProboLogger {\n constructor(prefix, level = ProboLogLevel.INFO) {\n this.prefix = prefix;\n this.level = level;\n }\n setLogLevel(level) {\n console.log(`[${this.prefix}] Setting log level to: ${level} (was: ${this.level})`);\n this.level = level;\n }\n shouldLog(level) {\n return logLevelOrder[level] >= logLevelOrder[this.level];\n }\n preamble(level) {\n const now = new Date();\n const hours = String(now.getHours()).padStart(2, '0');\n const minutes = String(now.getMinutes()).padStart(2, '0');\n const seconds = String(now.getSeconds()).padStart(2, '0');\n const milliseconds = String(now.getMilliseconds()).padStart(3, '0');\n return `[${hours}:${minutes}:${seconds}.${milliseconds}] [${this.prefix}] [${level}]`;\n }\n debug(...args) { if (this.shouldLog(ProboLogLevel.DEBUG))\n console.debug(this.preamble(ProboLogLevel.DEBUG), ...args); }\n info(...args) { if (this.shouldLog(ProboLogLevel.INFO))\n console.info(this.preamble(ProboLogLevel.INFO), ...args); }\n log(...args) { if (this.shouldLog(ProboLogLevel.LOG))\n console.log(this.preamble(ProboLogLevel.LOG), ...args); }\n warn(...args) { if (this.shouldLog(ProboLogLevel.WARN))\n console.warn(this.preamble(ProboLogLevel.WARN), ...args); }\n error(...args) { if (this.shouldLog(ProboLogLevel.ERROR))\n console.error(this.preamble(ProboLogLevel.ERROR), ...args); }\n }\n function doubleQuoteString(str) {\n if (!str)\n return '';\n return `\"${str.replace(/\"/g, '\\\\\"')}\"`;\n }\n\n new ProboLogger('apiclient');\n\n /**\n * Available AI models for LLM operations\n */\n var AIModel;\n (function (AIModel) {\n AIModel[\"AZURE_GPT4\"] = \"azure-gpt4\";\n AIModel[\"AZURE_GPT4_MINI\"] = \"azure-gpt4-mini\";\n AIModel[\"GEMINI_1_5_FLASH\"] = \"gemini-1.5-flash\";\n AIModel[\"GEMINI_2_5_FLASH\"] = \"gemini-2.5-flash\";\n AIModel[\"GPT4\"] = \"gpt4\";\n AIModel[\"GPT4_MINI\"] = \"gpt4-mini\";\n AIModel[\"CLAUDE_3_5\"] = \"claude-3.5\";\n AIModel[\"CLAUDE_SONNET_4_5\"] = \"claude-sonnet-4.5\";\n AIModel[\"CLAUDE_HAIKU_4_5\"] = \"claude-haiku-4.5\";\n AIModel[\"CLAUDE_OPUS_4_1\"] = \"claude-opus-4.1\";\n AIModel[\"GROK_2\"] = \"grok-2\";\n AIModel[\"LLAMA_4_SCOUT\"] = \"llama-4-scout\";\n AIModel[\"DEEPSEEK_V3\"] = \"deepseek-v3\";\n })(AIModel || (AIModel = {}));\n\n const highlighterLogger = new ProboLogger('highlighter');\n\n // Get text content of an element (recursively including child elements)\n function getElementText(element) {\n if (!element) return '';\n return element.textContent || '';\n }\n\n // Get placeholder text of an element\n function getElementPlaceholder(element) {\n if (!element) return '';\n return element.placeholder || '';\n }\n\n // Get CSS selector of an element (not recursively, just for a single element)\n function getElementCssSelector(element) {\n if (!(element instanceof Element)) return '';\n \n const tag = element.tagName.toLowerCase();\n let selector = tag;\n \n // Find nth-of-type for elements without ID\n let nth = 1;\n let sibling = element.previousElementSibling;\n while (sibling) {\n if (sibling.nodeName.toLowerCase() === tag) nth++;\n sibling = sibling.previousElementSibling;\n }\n if (nth > 1 || element.nextElementSibling) selector += `:nth-of-type(${nth})`;\n \n return selector;\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 // Helper function to check if element is a dropdown item\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 * Element Query Utilities\n */\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 highlighterLogger.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 * 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 robustQuerySelector(selector, root = document, all = false) {\n // First try to find in light DOM\n let elements = all ? root.querySelectorAll(selector) : root.querySelector(selector);\n if (elements) return elements;\n \n // Get all shadow roots\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 elements = robustQuerySelector(selector, el.shadowRoot, all);\n if (elements) return elements;\n }\n \n return all ? [] : null;\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 // unified path generation for xpath and css selector\n function extractElementPath(element) {\n if (!element) {\n highlighterLogger.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 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 = robustQuerySelector(`#${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 function filterNestedElements(nonZeroElements, parentCandidates, shouldKeepNestedElementFn) {\n /**\n * Filter elements based on parent/child relationships.\n * @param {Array} nonZeroElements - Elements sorted by depth (parents first)\n * @param {Set} parentCandidates - Set of all candidate parent CSS selectors (all nonZeroElements)\n * @param {Function} shouldKeepNestedElementFn - Function(element_info, parentPath, parentElementInfo) => boolean\n * @returns {Array} Filtered elements\n */\n return nonZeroElements.filter(element_info => {\n const parent = findClosestParent(parentCandidates, element_info);\n \n if (parent == null) {\n // No parent found, keep element\n return true;\n }\n \n // Find parent element_info\n const parentElementInfo = nonZeroElements.find(e => e.css_selector === parent);\n \n // Use custom function to determine if we should keep nested element\n return shouldKeepNestedElementFn(element_info, parent, parentElementInfo);\n });\n }\n\n function getDirectHandlers(element) {\n /**\n * Get direct handlers for an element (no bubbling check).\n * Returns array of handler objects.\n */\n const handlers = [];\n \n // Check onclick\n if (element.hasAttribute('onclick') || element.onclick) {\n handlers.push({\n type: 'onclick',\n source: element.hasAttribute('onclick') ? 'attribute' : 'property',\n value: element.getAttribute('onclick') || 'function'\n });\n }\n \n // Check React Fiber\n const reactKey = Object.keys(element).find(key => \n key.startsWith('__reactFiber') || key.startsWith('__reactInternalInstance')\n );\n if (reactKey) {\n const fiber = element[reactKey];\n if (fiber?.memoizedProps?.onClick || fiber?.pendingProps?.onClick) {\n handlers.push({\n type: 'react-onClick',\n source: 'react-fiber',\n hasMemoized: !!fiber?.memoizedProps?.onClick,\n hasPending: !!fiber?.pendingProps?.onClick\n });\n }\n }\n \n return handlers;\n }\n\n function getElementHandlersWithBubbling(element, depth = 0) {\n /**\n * Get handler info including bubbling (recursive DOM traversal).\n * Uses actual DOM relationships (parentElement) instead of CSS selector parsing.\n * Cursor uses getComputedStyle() which already includes inheritance.\n * @param {HTMLElement} element - The element to check\n * @param {number} depth - Recursion depth (safety limit)\n */\n // Safety check: null/undefined element\n if (!element) {\n return { handlers: [], cursorPointer: false, handlerSource: 'none' };\n }\n \n // Safety check: prevent infinite recursion (circular parent references)\n if (depth > 100) {\n throw new Error(`Recursion depth limit exceeded (${depth}) in getElementHandlersWithBubbling. Possible circular parent reference. Element: ${element.tagName || 'unknown'}`);\n }\n \n const directHandlers = getDirectHandlers(element);\n \n // If element has direct handlers, return them\n if (directHandlers.length > 0) {\n // Cursor: getComputedStyle already includes inheritance!\n let cursorPointer = false;\n try {\n const style = window.getComputedStyle(element);\n cursorPointer = style.cursor === 'pointer';\n } catch (e) {\n // getComputedStyle can fail in edge cases (e.g., detached elements)\n // Default to false\n }\n \n return {\n handlers: directHandlers,\n cursorPointer: cursorPointer,\n handlerSource: 'direct'\n };\n }\n \n // No direct handlers - check parent for bubbled handlers (using DOM, not CSS selectors)\n if (element.parentElement) {\n const parentResult = getElementHandlersWithBubbling(element.parentElement, depth + 1);\n if (parentResult.handlers.length > 0) {\n // Parent has handlers that would bubble to this element\n // Cursor: getComputedStyle already includes inheritance!\n let cursorPointer = false;\n try {\n const style = window.getComputedStyle(element);\n cursorPointer = style.cursor === 'pointer';\n } catch (e) {\n // getComputedStyle can fail in edge cases (e.g., detached elements)\n // Default to false\n }\n \n return {\n handlers: parentResult.handlers.map(h => ({\n ...h,\n source: 'bubbled'\n })),\n cursorPointer: cursorPointer,\n handlerSource: 'bubbled'\n };\n }\n }\n \n // No handlers at all\n let cursorPointer = false;\n try {\n const style = window.getComputedStyle(element);\n cursorPointer = style.cursor === 'pointer';\n } catch (e) {\n // getComputedStyle can fail in edge cases (e.g., detached elements)\n // Default to false\n }\n \n return {\n handlers: [],\n cursorPointer: cursorPointer,\n handlerSource: 'none'\n };\n }\n\n function normalizeHandlers(handlers) {\n /**\n * Normalize handlers for comparison (ignore source field, deduplicate).\n * Returns sorted array of unique normalized handler objects.\n */\n const seen = new Set();\n const normalized = [];\n \n for (const h of handlers) {\n // Safety check: skip malformed handlers\n if (!h || !h.type) continue;\n \n const key = h.type === 'react-onClick' \n ? 'react-onClick' \n : `${h.type}:${h.value || 'function'}`;\n \n if (!seen.has(key)) {\n seen.add(key);\n normalized.push({\n type: h.type,\n value: h.type === 'react-onClick' ? undefined : (h.value || 'function')\n });\n }\n }\n \n return normalized.sort((a, b) => {\n if (a.type !== b.type) return a.type.localeCompare(b.type);\n return (a.value || '').localeCompare(b.value || '');\n });\n }\n\n function compareHandlersForUniquify(childHandlers, parentHandlers) {\n /**\n * Compare handlers between child and parent for uniquification.\n * Returns true if handlers are different (should keep child), false if same (can unify).\n */\n const childNormalized = normalizeHandlers(childHandlers.handlers);\n const parentNormalized = normalizeHandlers(parentHandlers.handlers);\n \n const handlersEqual = JSON.stringify(childNormalized) === JSON.stringify(parentNormalized);\n const cursorEqual = childHandlers.cursorPointer === parentHandlers.cursorPointer;\n \n // If handlers or cursor differ, keep child (don't unify)\n return !handlersEqual || !cursorEqual;\n }\n\n function uniquifyElements(elements) {\n highlighterLogger.debug(`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 highlighterLogger.debug('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 highlighterLogger.debug('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 highlighterLogger.debug('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.depth - b.depth);\n highlighterLogger.debug(`After dimension filtering: ${nonZeroElements.length} elements remain (${elements.length - nonZeroElements.length} removed)`);\n \n // Create parent candidate set with ALL nonZeroElements (before filtering)\n // This allows findClosestParent to find any element as a parent, even if it gets filtered out later\n const parentCandidates = new Set(nonZeroElements.map(e => e.css_selector));\n \n // Filter nested elements using handler comparison and standard logic\n const filteredByParent = filterNestedElements(nonZeroElements, parentCandidates, (element_info, parent, parentElementInfo) => {\n return shouldKeepNestedElement(element_info, parent, parentElementInfo);\n });\n\n highlighterLogger.debug(`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 highlighterLogger.debug(`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 highlighterLogger.debug(`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(parentCandidates, element_info) { \n // Split the CSS selector into segments\n const segments = element_info.css_selector.split(' > ');\n \n // Try increasingly shorter paths until we find one in the parentCandidates set\n for (let i = segments.length - 1; i > 0; i--) {\n const parentPath = segments.slice(0, i).join(' > ');\n if (parentCandidates.has(parentPath)) {\n return parentPath;\n }\n }\n\n return null;\n }\n\n function shouldKeepNestedElement(elementInfo, parentPath, parentElementInfo) {\n // Check special cases FIRST (before handler comparison)\n const parentSegments = parentPath.split(' > ');\n\n // If parent is a link, don't keep child\n const isParentLink = /^a(:nth-of-type\\(\\d+\\))?$/.test(parentSegments[parentSegments.length - 1]);\n if (isParentLink) {\n return false; \n }\n \n // If this is a checkbox/radio input inside a label, don't keep the input (keep label instead)\n if (elementInfo.tag === 'input' && \n (elementInfo.type === 'checkbox' || elementInfo.type === 'radio')) {\n const isParentLabel = /^label(:nth-of-type\\(\\d+\\))?$/.test(parentSegments[parentSegments.length - 1]);\n if (isParentLabel) {\n return false;\n }\n }\n \n // Now check if handlers differ (only if special cases don't apply)\n if (parentElementInfo) {\n const childHandlers = getElementHandlersWithBubbling(elementInfo.element);\n const parentHandlers = getElementHandlersWithBubbling(parentElementInfo.element);\n \n // If handlers differ, keep child (don't unify)\n if (compareHandlersForUniquify(childHandlers, parentHandlers)) {\n return true;\n }\n }\n \n return isFormControl(elementInfo) || isDropdownItem(elementInfo) || isTableCell(elementInfo);\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 highlighterLogger.debug('[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 * 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 highlighterLogger.debug('🔍 [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 highlighterLogger.debug('🔍 [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 highlighterLogger.debug('🔍 [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 highlighterLogger.debug('🔍 [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 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 function findElementsWithPointer() {\n const elements = [];\n const allElements = getAllElementsIncludingShadow('*');\n \n allElements.forEach(element => {\n const style = window.getComputedStyle(element);\n const hasPointer = style.cursor === 'pointer';\n \n if (hasPointer) {\n elements.push(element);\n }\n });\n \n highlighterLogger.debug(`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 highlighterLogger.debug('Found inputs:', inputs.length, inputs);\n elements.push(...inputs);\n \n const textareas = [...getAllElementsIncludingShadow('textarea')];\n highlighterLogger.debug('Found textareas:', textareas.length);\n elements.push(...textareas);\n \n const editables = [...getAllElementsIncludingShadow('[contenteditable=\"true\"]')];\n highlighterLogger.debug('Found editables:', editables.length);\n elements.push(...editables);\n\n return elements;\n }\n\n // maximum number of characters in a logical name\n const MAX_LOGICAL_NAME_LENGTH = 25;\n \n /**\n * Query elements by CSS selector with :has-text(\"...\") pseudo-selector\n * @param {string} selector - CSS selector with optional :has-text(\"...\") pseudo-selector\n * @param {Element|Document} context - Context element to query within (defaults to document)\n * @returns {Element[]} Array of matched elements\n * @example\n * // Find all buttons with text \"Submit\"\n * querySelectorAllByText('button:has-text(\"Submit\")')\n * \n * // Find element with specific role and text\n * querySelectorAllByText('[role=\"button\"]:has-text(\"Click me\")')\n * \n * // Complex selector with descendants\n * querySelectorAllByText('button:has-text(\"foo\") > div > ul')\n * \n * // Multiple components\n * querySelectorAllByText('.container > button:has-text(\"Submit\")')\n * \n * // Multiple text filters\n * querySelectorAllByText('button:has-text(\"foo\") > div:has-text(\"bar\") > span')\n */\n function querySelectorAllByText(selector, context = document) {\n \t// Local helper function to compare two strings by normalizing whitespace and ignoring case\n \t// mimic the behavior of Playwright's :has-text() selector modifier\n \tfunction pwHasText(str1, str2) {\n \t\tif (!str1 || !str2) return false;\n \t\t\n \t\t// Normalize whitespace by replacing multiple spaces with single space and trim\n \t\tconst normalize = (str) => cleanText(str.toLowerCase());\n \t\t\n \t\tconst normalized1 = normalize(str1);\n \t\tconst normalized2 = normalize(str2);\n \t\t\n \t\treturn normalized1.includes(normalized2);\n \t}\n \t\n \t// Check if selector contains :has-text() pseudo-selector\n \tif (!selector.includes(':has-text(')) {\n \t\t// No :has-text pseudo-selector, use regular querySelectorAll\n \t\treturn Array.from(robustQuerySelector(selector, context, true));\n \t}\n \t\n \t// Split selector by combinators while preserving them\n \t// Matches: >, +, ~, or space (descendant combinator)\n \tconst parts = [];\n \tlet currentPart = '';\n \tlet inBracket = false;\n \tlet inTextPseudo = false;\n \tlet inTextQuotes = false;\n \tlet i = 0;\n \t\n \twhile (i < selector.length) {\n \t\tconst char = selector[i];\n \t\t\n \t\t// Track :has-text(\"...\") to avoid splitting inside it\n \t\tif (selector.substring(i, i + 10) === ':has-text(') {\n \t\t\tinTextPseudo = true;\n \t\t\tinTextQuotes = false;\n \t\t}\n \t\tif (inTextPseudo && char === '\"' && !inTextQuotes) {\n \t\t\tinTextQuotes = true;\n \t\t} else if (inTextPseudo && char === '\"' && inTextQuotes) {\n \t\t\t// Check if this quote is escaped\n \t\t\tif (i > 0 && selector[i - 1] === '\\\\') ; else {\n \t\t\t\tinTextQuotes = false;\n \t\t\t}\n \t\t}\n \t\tif (inTextPseudo && char === ')' && !inTextQuotes) {\n \t\t\tinTextPseudo = false;\n \t\t\tcurrentPart += char;\n \t\t\ti++;\n \t\t\tcontinue;\n \t\t}\n \t\t\n \t\t// Track brackets to avoid splitting on combinators inside attribute selectors\n \t\tif (char === '[') inBracket = true;\n \t\tif (char === ']') inBracket = false;\n \t\t\n \t\t// Check for combinators (not inside brackets or :has-text())\n \t\tif (!inBracket && !inTextPseudo && (char === '>' || char === '+' || char === '~')) {\n \t\t\tif (currentPart.trim()) parts.push({ selector: currentPart.trim(), combinator: null });\n \t\t\tparts.push({ selector: null, combinator: char.trim() });\n \t\t\tcurrentPart = '';\n \t\t\ti++;\n \t\t\tcontinue;\n \t\t}\n \t\t\n \t\t// Check for space combinator (descendant)\n \t\tif (!inBracket && !inTextPseudo && char === ' ') {\n \t\t\t// Skip multiple spaces\n \t\t\twhile (i < selector.length && selector[i] === ' ') i++;\n \t\t\t// Check if next char is a combinator (>, +, ~)\n \t\t\tif (i < selector.length && !'><+~'.includes(selector[i])) {\n \t\t\t\tif (currentPart.trim()) {\n \t\t\t\t\tparts.push({ selector: currentPart.trim(), combinator: null });\n \t\t\t\t\tparts.push({ selector: null, combinator: ' ' });\n \t\t\t\t\tcurrentPart = '';\n \t\t\t\t}\n \t\t\t}\n \t\t\tcontinue;\n \t\t}\n \t\t\n \t\tcurrentPart += char;\n \t\ti++;\n \t}\n \tif (currentPart.trim()) parts.push({ selector: currentPart.trim(), combinator: null });\n \t\n \t// Filter out combinator-only entries and rebuild as selector+combinator pairs\n \tconst selectorParts = [];\n \tfor (let j = 0; j < parts.length; j++) {\n \t\tif (parts[j].selector) {\n \t\t\tconst combinator = (j + 1 < parts.length && parts[j + 1].combinator) ? parts[j + 1].combinator : null;\n \t\t\t// Extract text value if present\n \t\t\tconst textMatch = parts[j].selector.match(/:has-text\\(\"((?:[^\"\\\\]|\\\\.)*)\"\\)/);\n \t\t\tconst textValue = textMatch ? textMatch[1] : null;\n \t\t\tconst selectorWithoutText = textMatch ? parts[j].selector.replace(/:has-text\\(\"((?:[^\"\\\\]|\\\\.)*)\"\\)/, '') : parts[j].selector;\n \t\t\t\n \t\t\tselectorParts.push({ \n \t\t\t\tselector: parts[j].selector,\n \t\t\t\tselectorWithoutText,\n \t\t\t\ttextValue,\n \t\t\t\tcombinator \n \t\t\t});\n \t\t}\n \t}\n \t\n \t// Process selector parts sequentially, applying text filters as we go\n \tlet currentElements = [context];\n \t\n \tfor (let partIndex = 0; partIndex < selectorParts.length; partIndex++) {\n \t\tconst part = selectorParts[partIndex];\n \t\tconst nextElements = [];\n \t\t\n \t\tfor (const ctx of currentElements) {\n \t\t\tlet candidates;\n \t\t\t\n \t\t\tif (partIndex === 0 && ctx === context) {\n \t\t\t\t// First part, query from document/context\n \t\t\t\tconst querySelector = part.selectorWithoutText.trim() || '*';\n \t\t\t\tcandidates = Array.from(robustQuerySelector(querySelector, ctx, true));\n \t\t\t\thighlighterLogger.debug(`querySelectorAllByText: first part, querySelector=${querySelector}, candidates=`, candidates);\n \t\t\t} \n \t\t\telse {\n \t\t\t\t// Subsequent parts, use combinator\n \t\t\t\tconst prevPart = selectorParts[partIndex - 1];\n \t\t\t\tconst combinator = prevPart.combinator;\n \t\t\t\tconst querySelector = part.selectorWithoutText.trim() || '*';\n \t\t\t\t\n \t\t\t\tif (combinator === '>') {\n \t\t\t\t\t// Direct children\n \t\t\t\t\tcandidates = Array.from(ctx.children).filter(child => {\n \t\t\t\t\t\treturn child.matches(querySelector);\n \t\t\t\t\t});\n \t\t\t\t} else if (combinator === '+') {\n \t\t\t\t\t// Next sibling\n \t\t\t\t\tconst sibling = ctx.nextElementSibling;\n \t\t\t\t\tcandidates = (sibling && sibling.matches(querySelector)) ? [sibling] : [];\n \t\t\t\t} else if (combinator === '~') {\n \t\t\t\t\t// Following siblings\n \t\t\t\t\tcandidates = [];\n \t\t\t\t\tlet sibling = ctx.nextElementSibling;\n \t\t\t\t\twhile (sibling) {\n \t\t\t\t\t\tif (sibling.matches(querySelector)) {\n \t\t\t\t\t\t\tcandidates.push(sibling);\n \t\t\t\t\t\t}\n \t\t\t\t\t\tsibling = sibling.nextElementSibling;\n \t\t\t\t\t}\n \t\t\t\t} \n \t\t\t\telse {\n \t\t\t\t\t// Descendant (space)\n \t\t\t\t\tcandidates = Array.from(robustQuerySelector(querySelector, ctx, true));\n \t\t\t\t}\n \t\t\t}\n \t\t\t\n \t\t\t// Apply text filter if present\n \t\t\tif (part.textValue !== null) {\n \t\t\t\tcandidates = candidates.filter(el => pwHasText(getElementText(el), part.textValue));\n \t\t\t}\n \t\t\t\n \t\t\tnextElements.push(...candidates);\n \t\t}\n \t\t\n \t\tcurrentElements = nextElements;\n \t\t\n \t\t// If no matches, bail early\n \t\tif (currentElements.length === 0) {\n \t\t\treturn [];\n \t\t}\n \t}\n \t\n \treturn currentElements;\n }\n\n const cleanText = (text) => {\n \treturn text.replace(/\\s+/g, ' ').trim();\n };\n\n // Helper function to get name selector\n const getNameSelector = (element) => {\n \tconst text = cleanText(getElementText(element));\n \tconst title = element.getAttribute('title');\n \tconst name = element.getAttribute('name');\n \tconst ariaLabel = element.getAttribute('aria-label');\n \tif (!text && !title && !name && !ariaLabel) throw new Error('No name attribute provided');\n \tif (text) return `:has-text(${doubleQuoteString(text)})`;\n \tif (title) return `[title=${doubleQuoteString(title)}]`;\n \tif (ariaLabel) return `[aria-label=${doubleQuoteString(ariaLabel)}]`;\n \treturn `[name=${doubleQuoteString(name)}]`; \n };\n\n const getLogicalName = (element) => {\n \tconst text = cleanText(getElementText(element));\n \tconst title = element.getAttribute('title');\n \tconst name = element.getAttribute('name');\n \tconst ariaLabel = element.getAttribute('aria-label');\n \tconst role = element.getAttribute('role');\n \tconst logicalName = text || title || name || ariaLabel;\n \treturn {\n \t\thasLogicalName: logicalName && logicalName.length < MAX_LOGICAL_NAME_LENGTH && !role?.startsWith('row'),\n \t\tlogicalName: logicalName\n \t};\n };\n\n\n /**\n * Individual strategy functions\n */\n\n function tryRoleStrategy(element) { \n \tconst role = element.getAttribute('role');\n \tconst tag = element.tagName.toLowerCase();\n \tconst { hasLogicalName, logicalName } = getLogicalName(element);\n \tif (role && hasLogicalName) {\n \t\treturn { \n \t\t\tstrategy: 'role', \n \t\t\tselector: `[role=${doubleQuoteString(role)}]${getNameSelector(element)}`\n \t\t};\n \t}\n \t\n \t// Tag-based role (button, a, input, etc.) + name\n \tif (['button', 'a', 'input', 'textarea', 'select'].includes(tag) && hasLogicalName) {\n \t\treturn { \n \t\t\tstrategy: 'role', \n \t\t\tselector: `${tag}${getNameSelector(element)}`\n \t\t};\n \t}\n \t\n \treturn null;\n }\n\n function tryPlaceholderStrategy(element) {\n \tconst placeholder = element.getAttribute('placeholder');\n \tif (placeholder) {\n \t\treturn { \n \t\t\tstrategy: 'placeholder', \n \t\t\tselector: `[placeholder=${doubleQuoteString(placeholder)}]` \n \t\t};\n \t}\n \treturn null;\n }\n\n function tryLabelStrategy(element) {\n \tconst tag = element.tagName.toLowerCase();\n \tif (['input', 'textarea', 'select'].includes(tag)) {\n \t\tconst id = element.id;\n \t\tif (id) {\n \t\t\tconst labels = robustQuerySelector(`label[for=${doubleQuoteString(id)}]`, document, true);\n \t\t\tif (labels.length === 1) { //make sure id is unique\n \t\t\t\tconst labelText = cleanText(getElementText(labels[0]));\n \t\t\t\tif (labelText) {\n \t\t\t\t\tconst labelSelector = `label:has-text(${doubleQuoteString(labelText)})`;\n \t\t\t\t\ttry {\n \t\t\t\t\t\tconst els = querySelectorAllByText(labelSelector, document);\n \t\t\t\t\t\tif (els.length === 1) {\n \t\t\t\t\t\t\treturn { \n \t\t\t\t\t\t\t\tstrategy: 'label for',\n \t\t\t\t\t\t\t\tlabel_selector: labelSelector\n \t\t\t\t\t\t\t};\n \t\t\t\t\t\t}\n \t\t\t\t\t} catch (error) {\n \t\t\t\t\t\thighlighterLogger.error(`internal error: while querying label selector ${labelSelector} for element ${element.outerHTML}`, error);\n \t\t\t\t\t\tthrow error;\n \t\t\t\t\t}\n \t\t\t\t}\n \t\t\t}\n \t\t}\n \t}\n \treturn null;\n }\n\n function tryAriaLabelledByStrategy(element) {\n \tconst arialabeledBy = element.getAttribute('aria-labelledby');\n \tconst tag = element.tagName.toLowerCase();\n \t\n \tif (arialabeledBy) {\n \t\tconst labels = robustQuerySelector(`#${CSS.escape(arialabeledBy)}`, document, true);\n \t\tif (labels.length === 1) {\n \t\t\tconst labelText = cleanText(getElementText(labels[0]));\n \t\t\tif (labelText) { //verify the label text is unique\n \t\t\t\tconst labelSelector = `${tag}:has-text(${doubleQuoteString(labelText)})`;\n \t\t\t\ttry {\n \t\t\t\t\tconst els = querySelectorAllByText(labelSelector, document);\n \t\t\t\t\tif (els.length === 1) {\n \t\t\t\t\t\treturn { \n \t\t\t\t\t\t\tstrategy: 'label by', \n \t\t\t\t\t\t\tlabel_selector: labelSelector\n \t\t\t\t\t\t};\n \t\t\t\t\t}\n \t\t\t\t} catch (error) {\n \t\t\t\t\thighlighterLogger.error(`internal error: while querying aria-labelledby selector ${labelSelector} for element ${element.outerHTML}`, error);\n \t\t\t\t\tthrow error;\n \t\t\t\t}\n \t\t\t}\n \t\t}\n \t}\n \treturn null;\n }\n\n // Only use text strategy if element is clickable/interactive \n function tryNameStrategy(element) {\t\n \tconst { hasLogicalName } = getLogicalName(element);\n \tconst classes = Array.from(element.classList).map(cls => CSS.escape(cls));\n \t\n \t// Only use name strategy if element is clickable/interactive \n \tif (isClickableElement(element) && hasLogicalName && classes.length > 0) {\n \t\treturn { \n \t\t\tstrategy: 'name', \n \t\t\tselector: `.${classes.join('.')}${getNameSelector(element)}` \n \t\t\t// selector: `${element.tagName.toLowerCase()}${getNameSelector(element)}`\n \t\t};\n \t}\n \treturn null;\n }\n\n function tryDataTestIdStrategy(element) {\n \tconst dataTestId = element.getAttribute('data-testid');\n \tif (dataTestId) {\n \t\treturn { \n \t\t\tstrategy: 'data-testid',\t\t\t\n \t\t\tselector: `[data-testid=${doubleQuoteString(dataTestId)}]` \n \t\t};\n \t}\n \treturn null;\n }\n\n function tryIdStrategy(element) {\n \tconst id = element.getAttribute('id');\n \tif (id && !/:|\\s|\\\\/.test(id)) { //avoid using id if it contain unusual characters (react creates ids like this: \":r:42\")\n \t\treturn { \n \t\t\tstrategy: 'id', \n \t\t\tselector: `#${CSS.escape(id)}` \n \t\t};\n \t}\n \treturn null;\n }\n\n // Try all strategies on an element in order of precedence\n function attemptStrategiesOnElement(element) {\n \tconst strategies = [\n \t\ttryRoleStrategy, \t\t\n \t\ttryAriaLabelledByStrategy,\n \t\ttryNameStrategy,\n \t\ttryLabelStrategy,\n \t\ttryDataTestIdStrategy,\n \t\ttryPlaceholderStrategy,\n \t\ttryIdStrategy,\t\t\n \t\t// tryClassStrategy\n \t];\n \t\n \tfor (const strategy of strategies) {\n \t\tconst result = strategy(element); \n \t\tif (result) { \n \t\t\treturn result;\n \t\t}\n \t}\n \treturn null;\n }\n\n /**\n * Generate a smart selector for an element\n * @param {Element} element - The element to generate a locator for\n * @param {string} childPath - The child path to generate a locator for\n * @returns {Object} The selector for the element\n */\n function generateSmartSelector(element, childPath = '') {\n \t// Terminate recursion\n \tif (!element || element.nodeType !== Node.ELEMENT_NODE) {\n \t\treturn {\n \t\t\tstrategy: 'css',\n \t\t\tselector: childPath\n \t\t};\n \t}\n \t\n \t// Try strategies 1-6 on current element\n \tconst result = attemptStrategiesOnElement(element);\n \tconst is_label = result?.strategy === 'label for' || result?.strategy === 'label by';\n\n \tif (result) { // found a strategy\t\t\t\n \t\t// If we have a child path, concatenate parent strategy result with child CSS path \n \t\tlet extendedResult = result; \n \t\tif (childPath) {\n \t\t\tconst selector = is_label ? `${childPath}` : `${result?.selector} > ${childPath}`;\n \t\t\textendedResult = {\n \t\t\t\t...extendedResult,\n \t\t\t\tselector: selector,\n \t\t\t\tstrategy: `${extendedResult.strategy} (concatenated with child CSS path)`\n \t\t\t};\n \t\t} \t\t\n \t\t// check if the combined selector is unique and if not add the index to the strategy\n \t\tif (!is_label) { //label element is already checked for uniqueness\n \t\t\ttry {\n \t\t\t\tconst matchedElements = querySelectorAllByText(extendedResult?.selector); //query full selector for uniqueness checking \n \t\t\t\thighlighterLogger.debug(`querySelectorAllByText(${extendedResult?.selector}) matched ${matchedElements.length} elements`);\n \t\t\t\thighlighterLogger.debug(matchedElements);\n \t\t\t\tif (matchedElements.length > 1) {\n \t\t\t\t\tconst childElement = childPath ? robustQuerySelector(`:scope > ${childPath}`, element) : element;\n \t\t\t\t\tconst index = matchedElements.findIndex(el => el === childElement);\n \t\t\t\t\tif (index === -1) {\n \t\t\t\t\t\thighlighterLogger.error('internal error: Element not found in matched elements', element, result?.selector, matchedElements);\n \t\t\t\t\t\treturn null;\n \t\t\t\t\t}\n \t\t\t\t\textendedResult = {\n \t\t\t\t\t\t...extendedResult,\n \t\t\t\t\t\tstrategy: `${extendedResult.strategy} (by index)`,\n \t\t\t\t\t\tindex,\n \t\t\t\t\t}; \n \t\t\t\t}\n \t\t\t} catch (error) {\n \t\t\t\thighlighterLogger.error(`internal error: while checking if selector ${result?.selector} is unique for element ${element.outerHTML}`, error);\n \t\t\t\tthrow error;\n \t\t\t}\t\t\t\n \t\t}\n \t\treturn extendedResult;\n \t}\n \t\n \t// Get CSS selector for current element and build child path \n \tlet newChildPath;\n \t// didn't find strategy\n \tconst elementSelector = getElementCssSelector(element);\n \tnewChildPath = childPath ? `${elementSelector} > ${childPath}` : elementSelector;\n \tconst numComponents = newChildPath.split(' > ').length;\n \tif (numComponents > 2) { //require at least 2 components for css strategy\n \t\t// check if the combined selector is unique\n \t\tconst matchedElements = robustQuerySelector(newChildPath, document, true);\n \t\tif (matchedElements.length === 1) {\n \t\t\treturn {\n \t\t\t\tstrategy: 'css',\t\t\t\t\n \t\t\t\tselector: newChildPath\n \t\t\t};\n \t\t}\n \t}\n\n \t// Recursively try parent element\n \treturn generateSmartSelector(getParentNode(element), newChildPath);\n }\n\n const findElementByInfo = (elementInfo, useSmartSelectors = false) => {\n \thighlighterLogger.debug('findElementByInfo:', elementInfo);\n \tif (useSmartSelectors) {\n \t return findElementBySelectors(elementInfo.smart_iframe_selector, elementInfo.smart_selector, true);\n \t}\n \treturn findElementBySelectors(elementInfo.iframe_selector, elementInfo.css_selector, false);\n };\n\n const compareSelectors = (selector1, selector2, useSmartSelectors = false) => {\n \tif (useSmartSelectors) {\n \t\treturn JSON.stringify(selector1) === JSON.stringify(selector2);\n \t}\n \treturn selector1 === selector2;\n };\n\n const findElementBySelectors = (iframeSelector, elementSelector, useSmartSelectors = false) => {\n \tlet element;\n \t\n \t// first find the iframe that contains the element\n \tif (iframeSelector) { \n \t const frames = getAllDocumentElementsIncludingShadow('iframe', document);\n \t \n \t // Iterate over all frames and compare their CSS selectors\n \t for (const frame of frames) {\n \t\tconst selector = useSmartSelectors ? generateSmartSelector(frame) : generateCssPath(frame);\n \t\tif (compareSelectors(selector, iframeSelector, useSmartSelectors)) {\n \t\t const frameDocument = frame.contentDocument || frame.contentWindow.document;\n \t\t element = useSmartSelectors ? findElementBySmartSelector(elementSelector, frameDocument) : robustQuerySelector(elementSelector, frameDocument);\t\t \n \t\t break;\n \t\t} \n \t }\t}\n \telse {\t\t\n \t\telement = useSmartSelectors ? findElementBySmartSelector(elementSelector, document) : robustQuerySelector(elementSelector, document);\t\t\n \t}\n \t \n \tif (element) {\n \t\thighlighterLogger.debug('findElementBySelectors: found element ', element);\n \t}\n \telse {\n \t highlighterLogger.warn('findElementBySelectors: failed to find element with CSS selector:', JSON.stringify(elementSelector));\n \t}\n \n \treturn element;\n };\n \n function findElementBySmartSelector(smartSelector, root = document) {\t\n \thighlighterLogger.debug(`findElementBySmartSelector: called with smartSelector=${JSON.stringify(smartSelector)}`);\n \tlet combinedSelector;\n \tconst mainStrategy = smartSelector.strategy.split(' ')[0]; //extract the main strategy from the full strategy string\n \tswitch(mainStrategy) {\n case 'role': \n case 'placeholder':\n case 'name':\n case 'data-testid':\n case 'id':\n case 'css':\n case 'class':\n \t\t highlighterLogger.debug(`findElementBySmartSelector: strategy='${smartSelector.strategy}', selector=${smartSelector?.selector}, index=${smartSelector?.index}`);\n if (typeof smartSelector?.index === 'number') {\n return querySelectorAllByText(smartSelector.selector, root)[smartSelector.index];\n }\n else {\n return querySelectorAllByText(smartSelector.selector, root)[0];\n }\t\t \n case 'label':\n \t\t\tif (smartSelector.strategy === 'label for') {\n \t\t\t\thighlighterLogger.debug(`findElementBySmartSelector: strategy='label for', label_selector=${smartSelector.label_selector}`);\n \t\t\t\tconst label_for = querySelectorAllByText(smartSelector.label_selector, root)[0];\n \t\t\t\tcombinedSelector = smartSelector?.selector ? `#${label_for.getAttribute('for')} > ${smartSelector.selector}` : `#${label_for.getAttribute('for')}`;\n \t\t\t\treturn robustQuerySelector(combinedSelector, root);\n \t\t\t}\n \t\t\telse if (smartSelector.strategy === 'label by') {\n \t\t\t\thighlighterLogger.debug(`findElementBySmartSelector: strategy='label by', label_selector=${smartSelector.label_selector}`);\n \t\t\t\tconst label_by = querySelectorAllByText(smartSelector.label_selector, root)[0];\n \t\t\t\tcombinedSelector = smartSelector?.selector ? `[aria-labelledby=\"${label_by.getAttribute('id')}\"] > ${smartSelector.selector}` : `[aria-labelledby=\"${label_by.getAttribute('id')}\"]`;\n \t\t\t\treturn robustQuerySelector(combinedSelector, root);\n \t\t\t}\n \t\t\telse {\n \t\t\t\thighlighterLogger.error(`findElementBySmartSelector: unsupported label strategy: ${smartSelector.strategy}`);\n \t\t\t\treturn null;\n \t\t\t} \n default:\n highlighterLogger.error(`findElementBySmartSelector: Unsupported smart selector strategy: ${smartSelector.strategy}`);\n return null;\n }\n }\n\n function getElementInfo(element, index) { \t\t\t\n \tconst iframe = getContainingIFrame(element); \n \tconst iframe_selector = iframe ? generateCssPath(iframe) : \"\";\t\n \tconst smart_iframe_selector = iframe ? generateSmartSelector(iframe) : null;\n \t\n \t// Return element info with pre-calculated values\n \treturn {\n \t\tindex: index ?? -1,\n \t\ttag: element.tagName.toLowerCase(),\n \t\ttype: element.type || '',\n \t\ttext: getElementText(element) || getElementPlaceholder(element),\n \t\thtml: cleanHTML(element.outerHTML),\n \t\txpath: generateXPath(element),\n \t\tcss_selector: generateCssPath(element),\n \t\tbounding_box: element.getBoundingClientRect(),\t\t\n \t\tiframe_selector: iframe_selector,\t\t\n \t\tsmart_selector: generateSmartSelector(element),\n \t\tsmart_iframe_selector: smart_iframe_selector,\n \t\telement: element,\n \t\tdepth: getElementDepth(element)\n \t};\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 highlighterLogger.debug('✅ 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 highlighterLogger.debug('✅ 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 highlighterLogger.debug('⚠️ 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 highlighterLogger.debug('📏 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 highlighterLogger.debug('✅ 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 highlighterLogger.error('❌ Missing required properties in actualInteractionElementInfo');\n return null;\n }\n\n highlighterLogger.debug('🔍 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 highlighterLogger.debug('❌ 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 highlighterLogger.debug('❌ 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 highlighterLogger.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, false,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 highlighterLogger.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 async function findElements(elementTypes, verbose=true) {\n const typesArray = Array.isArray(elementTypes) ? elementTypes : [elementTypes];\n highlighterLogger.debug('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 highlighterLogger.debug(`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 highlighterLogger.debug(`Out of which ${visibleElements.length} elements are visible:`);\n if (verbose) {\n visibleElements.forEach(info => {\n highlighterLogger.debug(`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, enableSmartSelectors=false, 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;\n if (!element) {\n element = findElementByInfo(elementInfo, enableSmartSelectors);\n if (!element) {\n highlighterLogger.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 highlighterLogger.debug(\"[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 highlighterLogger.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.toString();\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 highlighterLogger.debug('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(iframeSelector, elementSelector, isHover=false, useSmartSelectors=false) {\n // console.log('[ProboLabs] findAndCacheActualElement called with:', cssSelector, iframeSelector);\n highlighterLogger.debug(`[ProboLabs] findAndCacheActualElement called with: iframeSelector=${iframeSelector}, elementSelector=${elementSelector}, useSmartSelectors=${useSmartSelectors}`);\n let el = findElementBySelectors(iframeSelector, elementSelector, useSmartSelectors);\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 highlighterLogger.debug(`[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 highlighterLogger.debug('[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 window.ProboLabs.setLoggerDebugLevel = function(debugLevel) {\n highlighterLogger.setLogLevel(debugLevel);\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.deserializeNodeFromJSON = deserializeNodeFromJSON;\n exports.detectScrollableContainers = detectScrollableContainers;\n exports.findElementByInfo = findElementByInfo;\n exports.findElementBySelectors = findElementBySelectors;\n exports.findElements = findElements;\n exports.generateCssPath = generateCssPath;\n exports.generateSmartSelector = generateSmartSelector;\n exports.getAriaLabelledByText = getAriaLabelledByText;\n exports.getContainingIFrame = getContainingIFrame;\n exports.getElementInfo = getElementInfo;\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
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ import { exec, spawn } from 'child_process';
6
+ import { promisify } from 'util';
7
+ import 'adm-zip';
8
+ import fetch$1 from 'node-fetch';
9
+
2
10
  /**
3
11
  * Element tag constants for different types of interactive elements
4
12
  */
@@ -56,6 +64,9 @@ var PlaywrightAction;
56
64
  PlaywrightAction["WAIT_FOR"] = "WAIT_FOR";
57
65
  PlaywrightAction["WAIT_FOR_OTP"] = "WAIT_FOR_OTP";
58
66
  PlaywrightAction["GEN_TOTP"] = "GEN_TOTP";
67
+ // Sequence marker actions (not real interactions, used only for grouping)
68
+ PlaywrightAction["SEQUENCE_START"] = "SEQUENCE_START";
69
+ PlaywrightAction["SEQUENCE_END"] = "SEQUENCE_END";
59
70
  })(PlaywrightAction || (PlaywrightAction = {}));
60
71
 
61
72
  /**
@@ -114,6 +125,11 @@ var WebSocketsMessageType;
114
125
  WebSocketsMessageType["INTERACTION_APPLY_AI_SUMMARY_COMPLETED"] = "INTERACTION_APPLY_AI_SUMMARY_COMPLETED";
115
126
  WebSocketsMessageType["INTERACTION_APPLY_AI_SUMMARY_ERROR"] = "INTERACTION_APPLY_AI_SUMMARY_ERROR";
116
127
  WebSocketsMessageType["OTP_RETRIEVED"] = "OTP_RETRIEVED";
128
+ WebSocketsMessageType["TEST_SUITE_RUN_START"] = "TEST_SUITE_RUN_START";
129
+ WebSocketsMessageType["TEST_SUITE_RUN_LOG"] = "TEST_SUITE_RUN_LOG";
130
+ WebSocketsMessageType["TEST_SUITE_RUN_COMPLETE"] = "TEST_SUITE_RUN_COMPLETE";
131
+ WebSocketsMessageType["TEST_SUITE_RUN_ERROR"] = "TEST_SUITE_RUN_ERROR";
132
+ WebSocketsMessageType["TEST_SUITE_RUN_REPORTER_EVENT"] = "TEST_SUITE_RUN_REPORTER_EVENT";
117
133
  })(WebSocketsMessageType || (WebSocketsMessageType = {}));
118
134
 
119
135
  /**
@@ -165,6 +181,119 @@ class ProboLogger {
165
181
  error(...args) { if (this.shouldLog(ProboLogLevel.ERROR))
166
182
  console.error(this.preamble(ProboLogLevel.ERROR), ...args); }
167
183
  }
184
+ // Element cleaner logging
185
+ // const elementLogger = new ProboLogger('element-cleaner');
186
+ /**
187
+ * Cleans and returns a minimal element info structure.
188
+ */
189
+ //TODO: is this needed?
190
+ /* export function cleanupElementInfo(elementInfo: ElementInfo): CleanElementInfo {
191
+ elementLogger.debug(
192
+ `Cleaning up element info for ${elementInfo.tag} at index ${elementInfo.index}`
193
+ );
194
+ const depth = elementInfo.depth ?? elementInfo.getDepth();
195
+ const cleanEl = {
196
+ index: elementInfo.index,
197
+ tag: elementInfo.tag,
198
+ type: elementInfo.type,
199
+ text: elementInfo.text,
200
+ html: elementInfo.html,
201
+ xpath: elementInfo.xpath,
202
+ css_selector: elementInfo.css_selector,
203
+ iframe_selector: elementInfo.iframe_selector,
204
+ bounding_box: elementInfo.bounding_box,
205
+ depth
206
+ };
207
+ elementLogger.debug(`Cleaned element: ${JSON.stringify(cleanEl)}`);
208
+ return cleanEl;
209
+ } */
210
+ /**
211
+ * Cleans highlighted elements in an instruction payload.
212
+ */
213
+ /* export function cleanupInstructionElements(instruction: any): any {
214
+ if (!instruction?.result?.highlighted_elements) {
215
+ elementLogger.debug('No highlighted elements to clean');
216
+ return instruction;
217
+ }
218
+ elementLogger.debug(
219
+ `Cleaning ${instruction.result.highlighted_elements.length} highlighted elements`
220
+ );
221
+ const cleaned = {
222
+ ...instruction,
223
+ result: {
224
+ ...instruction.result,
225
+ highlighted_elements: instruction.result.highlighted_elements.map(
226
+ (el: ElementInfo) => cleanupElementInfo(el)
227
+ )
228
+ }
229
+ };
230
+ elementLogger.debug('Instruction cleaning completed');
231
+ return cleaned;
232
+ } */
233
+ // Determine whether an interaction can return a value
234
+ const hasReturnValue = (i) => {
235
+ return [
236
+ PlaywrightAction.EXTRACT_VALUE,
237
+ PlaywrightAction.ASK_AI,
238
+ PlaywrightAction.EXECUTE_SCRIPT
239
+ ].includes(i.action);
240
+ };
241
+ const getReturnValueParameterName = (i) => {
242
+ switch (i.action) {
243
+ case PlaywrightAction.EXTRACT_VALUE:
244
+ case PlaywrightAction.EXECUTE_SCRIPT:
245
+ return i.parameterName;
246
+ case PlaywrightAction.ASK_AI:
247
+ return i.parameterName.replace(/^assert_/, '');
248
+ default:
249
+ console.error(`Action ${i.action} has no return value`);
250
+ return '';
251
+ }
252
+ };
253
+ // Determine whether an interaction can be parameterized
254
+ const isParameterizable = (i) => {
255
+ const parameterizableActions = [
256
+ PlaywrightAction.FILL_IN,
257
+ PlaywrightAction.SELECT_DROPDOWN,
258
+ PlaywrightAction.SET_SLIDER,
259
+ PlaywrightAction.ASSERT_CONTAINS_VALUE,
260
+ PlaywrightAction.ASSERT_EXACT_VALUE,
261
+ PlaywrightAction.VISIT_URL,
262
+ PlaywrightAction.ASSERT_URL,
263
+ PlaywrightAction.UPLOAD_FILES,
264
+ PlaywrightAction.WAIT_FOR,
265
+ PlaywrightAction.GEN_TOTP,
266
+ PlaywrightAction.WAIT_FOR_OTP
267
+ ];
268
+ return parameterizableActions.includes(i.action) || (i.action === PlaywrightAction.ASK_AI && i.argument);
269
+ };
270
+ // Determine whether an interaction is AI-related
271
+ const isAI = (i) => {
272
+ var _a, _b, _c, _d, _e, _f;
273
+ return !['TYPE_KEYS', 'VISIT_URL', 'EXECUTE_SCRIPT'].includes(i.action) &&
274
+ (i.action === PlaywrightAction.ASK_AI ||
275
+ (((_b = (_a = i.serverResponse) === null || _a === void 0 ? void 0 : _a.result) === null || _b === void 0 ? void 0 : _b.prompt) && (((_d = (_c = i.serverResponse) === null || _c === void 0 ? void 0 : _c.result) === null || _d === void 0 ? void 0 : _d.error) === "" || !((_f = (_e = i.serverResponse) === null || _e === void 0 ? void 0 : _e.result) === null || _f === void 0 ? void 0 : _f.error))));
276
+ };
277
+ function singleQuoteString(str) {
278
+ if (!str)
279
+ return '';
280
+ return `'${str.replace(/'/g, "\\'")}'`;
281
+ }
282
+ /**
283
+ * Converts a string to a filesystem-safe slug.
284
+ * Used for filenames/package names (not URL parsing).
285
+ */
286
+ function slugify(text) {
287
+ if (!text)
288
+ return 'scenario';
289
+ return text
290
+ .toLowerCase()
291
+ .trim()
292
+ .replace(/[^a-zA-Z0-9-_]/g, '-') // Replace non-alphanumeric chars with hyphens
293
+ .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
294
+ .replace(/^-|-$/g, '') // Remove leading/trailing hyphens
295
+ .substring(0, 100); // Limit length to 100 chars
296
+ }
168
297
  function matchRegex(str, regex) {
169
298
  //parse the regex string
170
299
  const match = regex.match(/^\/(.+)\/([gimsuy]*)$/);
@@ -1060,10 +1189,10 @@ var AIModel;
1060
1189
  })(AIModel || (AIModel = {}));
1061
1190
 
1062
1191
  const DEFAULT_PLAYWRIGHT_TIMEOUT_CONFIG = {
1063
- highlightTimeout: 500,
1192
+ highlightTimeout: 300,
1064
1193
  playwrightActionTimeout: 5000,
1065
1194
  playwrightNavigationTimeout: 10000,
1066
- playwrightLocatorTimeout: 15000,
1195
+ playwrightLocatorTimeout: 2000,
1067
1196
  // mutation observer
1068
1197
  mutationsTimeout: 500,
1069
1198
  mutationsInitTimeout: 1000,
@@ -1073,10 +1202,733 @@ const DEFAULT_PLAYWRIGHT_TIMEOUT_CONFIG = {
1073
1202
  waitForNavigationGlobalTimeout: 15000,
1074
1203
  // wait for stability
1075
1204
  waitForStabilityQuietTimeout: 2000,
1076
- waitForStabilityInitialDelay: 500,
1205
+ waitForStabilityInitialDelay: 100,
1077
1206
  waitForStabilityGlobalTimeout: 15000,
1078
1207
  waitForStabilityVerbose: false,
1208
+ scriptTimeout: 120000, // 2 minutes
1209
+ };
1210
+
1211
+ const DEFAULT_RECORDER_SETTINGS = {
1212
+ // API Configuration
1213
+ apiKey: '',
1214
+ apiEndPoint: 'https://api.probolabs.ai',
1215
+ frontendUrl: 'https://app.probolabs.ai',
1216
+ baseUrl: undefined,
1217
+ // Scenario Configuration
1218
+ scenarioName: 'new recording',
1219
+ scenarioId: undefined,
1220
+ aiModel: 'azure-gpt4-mini',
1221
+ activeParamSet: 0,
1222
+ // Browser Configuration
1223
+ resetBrowserBeforeReplay: true,
1224
+ enableSmartSelectors: true, // Smart selectors always enabled
1225
+ // Script Configuration
1226
+ scriptTimeout: DEFAULT_PLAYWRIGHT_TIMEOUT_CONFIG.scriptTimeout,
1227
+ // Logging Configuration
1228
+ enableConsoleLogs: true,
1229
+ debugLevel: 'DEBUG',
1230
+ // Timeout Configuration (spread from PlaywrightTimeoutConfig)
1231
+ ...DEFAULT_PLAYWRIGHT_TIMEOUT_CONFIG,
1232
+ };
1233
+
1234
+ // --- Code generation utilities for Probo Labs Playwright scripts ---
1235
+ /**
1236
+ * Extracts environment variable names from parameter table rows
1237
+ * by parsing ${process.env.VAR_NAME} patterns from parameter values
1238
+ *
1239
+ * @param rows - Array of parameter table rows (Record<string, string>)
1240
+ * @returns Set of unique environment variable names (in uppercase)
1241
+ */
1242
+ function extractRequiredEnvVars(rows) {
1243
+ const envVars = new Set();
1244
+ // Return empty set if rows are not provided or empty
1245
+ if (!rows || !Array.isArray(rows) || rows.length === 0) {
1246
+ return envVars;
1247
+ }
1248
+ // Regex to match both escaped and unescaped process.env patterns
1249
+ // Matches: ${process.env.VAR_NAME} or \${process.env.VAR_NAME}
1250
+ // Pattern: optional backslash (\\), then ${ (escaped as \$\{), then process.env., then variable name
1251
+ const envVarPattern = /\\?\$\{process\.env\.([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
1252
+ // Scan all parameter values in all rows for env var patterns
1253
+ rows.forEach((row) => {
1254
+ if (row && typeof row === 'object') {
1255
+ Object.values(row).forEach((value) => {
1256
+ if (typeof value === 'string') {
1257
+ let match;
1258
+ while ((match = envVarPattern.exec(value)) !== null) {
1259
+ const varName = match[1].toUpperCase(); // Ensure uppercase for consistency
1260
+ envVars.add(varName);
1261
+ }
1262
+ // Reset regex lastIndex for next iteration
1263
+ envVarPattern.lastIndex = 0;
1264
+ }
1265
+ });
1266
+ }
1267
+ });
1268
+ return envVars;
1269
+ }
1270
+ /**
1271
+ * Extracts the list of required environment variable names from parameter table rows
1272
+ * @param rows - Array of parameter table rows (Record<string, string>)
1273
+ * @returns Array of required environment variable names (sorted)
1274
+ */
1275
+ function getRequiredEnvVars(rows) {
1276
+ const envVars = extractRequiredEnvVars(rows);
1277
+ return Array.from(envVars).sort();
1278
+ }
1279
+ /**
1280
+ * Generates Playwright native code for a given interaction.
1281
+ *
1282
+ * @param interaction - The interaction object containing action, element info, and other metadata.
1283
+ * @returns A string of Playwright code that performs the specified interaction.
1284
+ */
1285
+ function interactionToNativeCode(interaction) {
1286
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
1287
+ const selector = ((_a = interaction.elementInfo) === null || _a === void 0 ? void 0 : _a.css_selector) || '';
1288
+ const iframe = ((_b = interaction.elementInfo) === null || _b === void 0 ? void 0 : _b.iframe_selector) || '';
1289
+ const smartSelector = ((_c = interaction.elementInfo) === null || _c === void 0 ? void 0 : _c.smart_selector) || null;
1290
+ const smartIFrameSelector = ((_d = interaction.elementInfo) === null || _d === void 0 ? void 0 : _d.smart_iframe_selector) || null;
1291
+ const argument = isParameterizable(interaction) ? `param.${interaction.parameterName}` : singleQuoteString(interaction.argument);
1292
+ // Escape the nativeDescription for use in test.step() string literal
1293
+ const escapedStepName = interaction.nativeDescription.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1294
+ const comment = `
1295
+ // ${interaction.nativeDescription}${interaction.annotation ? `\n // annotation: ${interaction.annotation}` : ''}`;
1296
+ switch (interaction.action) {
1297
+ case PlaywrightAction.CLICK:
1298
+ case PlaywrightAction.CHECK_CHECKBOX:
1299
+ case PlaywrightAction.SELECT_RADIO:
1300
+ case PlaywrightAction.FILL_IN:
1301
+ case PlaywrightAction.TYPE_KEYS:
1302
+ case PlaywrightAction.SET_SLIDER:
1303
+ case PlaywrightAction.SELECT_DROPDOWN:
1304
+ case PlaywrightAction.ASSERT_CONTAINS_VALUE:
1305
+ case PlaywrightAction.ASSERT_EXACT_VALUE:
1306
+ case PlaywrightAction.HOVER:
1307
+ case PlaywrightAction.SCROLL_TO_ELEMENT:
1308
+ case PlaywrightAction.UPLOAD_FILES:
1309
+ case PlaywrightAction.VISIT_URL:
1310
+ case PlaywrightAction.VISIT_BASE_URL:
1311
+ case PlaywrightAction.ASSERT_URL:
1312
+ case PlaywrightAction.WAIT_FOR:
1313
+ case PlaywrightAction.WAIT_FOR_OTP:
1314
+ case PlaywrightAction.GEN_TOTP:
1315
+ const args = [
1316
+ `action: PlaywrightAction.${interaction.action}`,
1317
+ ...(argument ? [`argument: ${argument}`] : []),
1318
+ ...(iframe ? [`iframeSelector: '${iframe}'`] : []),
1319
+ ...(selector ? [`elementSelector: '${selector}'`] : []),
1320
+ ...(smartSelector ? [`smartSelector: ${JSON.stringify(smartSelector)}`] : []),
1321
+ ...(smartIFrameSelector ? [`smartIFrameSelector: ${JSON.stringify(smartIFrameSelector)}`] : []),
1322
+ ...(interaction.annotation ? [`annotation: '${interaction.annotation}'`] : []),
1323
+ ...(((_e = interaction.waitForConfig) === null || _e === void 0 ? void 0 : _e.pollingInterval) ? [`pollingInterval: ${(_f = interaction.waitForConfig) === null || _f === void 0 ? void 0 : _f.pollingInterval}`] : []),
1324
+ ...(((_g = interaction.waitForConfig) === null || _g === void 0 ? void 0 : _g.timeout) ? [`timeout: ${(_h = interaction.waitForConfig) === null || _h === void 0 ? void 0 : _h.timeout}`] : []),
1325
+ // Always include totpConfig for GEN_TOTP actions, using defaults if not present in interaction
1326
+ ...(interaction.action === PlaywrightAction.GEN_TOTP ? [`totpConfig: { digits: ${((_j = interaction.totpConfig) === null || _j === void 0 ? void 0 : _j.digits) || 6}, algorithm: '${((_k = interaction.totpConfig) === null || _k === void 0 ? void 0 : _k.algorithm) || 'SHA1'}' }`] : [])
1327
+ ];
1328
+ return `${comment}
1329
+ await test.step("${escapedStepName}", async () => {
1330
+ await ppw.runStep({
1331
+ ${args.join(',\n ')}
1332
+ });
1333
+ });`;
1334
+ case PlaywrightAction.EXTRACT_VALUE:
1335
+ const extractVarName = getReturnValueParameterName(interaction);
1336
+ return `${comment}
1337
+ await test.step("${escapedStepName}", async () => {
1338
+ const ${extractVarName} = await ppw.runStep({
1339
+ iframeSelector: '${iframe}',
1340
+ elementSelector: '${selector}',
1341
+ smartSelector: ${JSON.stringify(smartSelector)},
1342
+ smartIFrameSelector: ${JSON.stringify(smartIFrameSelector)},
1343
+ action: '${interaction.action}',
1344
+ annotation: '${(_l = interaction.annotation) !== null && _l !== void 0 ? _l : ""}',
1345
+ });
1346
+ param['${extractVarName}'] = ${extractVarName};
1347
+ });`;
1348
+ case PlaywrightAction.EXECUTE_SCRIPT:
1349
+ return generateExecuteScriptCode(interaction, comment, escapedStepName);
1350
+ default:
1351
+ return `// Unhandled action: ${interaction.action}!!!`;
1352
+ }
1353
+ }
1354
+ function interactionToProboLib(interaction) {
1355
+ var _a, _b, _c;
1356
+ const escapedPrompt = (_c = (_b = (_a = interaction.serverResponse) === null || _a === void 0 ? void 0 : _a.result) === null || _b === void 0 ? void 0 : _b.prompt) === null || _c === void 0 ? void 0 : _c.replace(/'/g, "\\'");
1357
+ const argument = isParameterizable(interaction) ? `param.${interaction.parameterName}` : `'${interaction.argument}'`;
1358
+ // Escape the nativeDescription for use in test.step() string literal
1359
+ const escapedStepName = interaction.nativeDescription.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1360
+ const comment = `
1361
+ // ${interaction.nativeDescription}${interaction.annotation ? `\n // annotation: ${interaction.annotation}` : ''}`;
1362
+ if (interaction.action === PlaywrightAction.EXTRACT_VALUE) {
1363
+ const extractVarName = getReturnValueParameterName(interaction);
1364
+ return `${comment}
1365
+ await test.step("${escapedStepName}", async () => {
1366
+ const ${extractVarName} = await probo.runStep(page, '${escapedPrompt}', null, { stepId: ${interaction.stepId} });
1367
+ param['${extractVarName}'] = ${extractVarName};
1368
+ });`;
1369
+ }
1370
+ else if (interaction.action === PlaywrightAction.ASSERT_CONTAINS_VALUE ||
1371
+ interaction.action === PlaywrightAction.ASSERT_EXACT_VALUE) {
1372
+ return `${comment}
1373
+ await test.step("${escapedStepName}", async () => {
1374
+ await probo.runStep(page, '${escapedPrompt}', ${argument}, { stepId: ${interaction.stepId} });
1375
+ });`;
1376
+ }
1377
+ else if (interaction.action === PlaywrightAction.VISIT_URL) {
1378
+ return `${comment}
1379
+ await test.step("${escapedStepName}", async () => {
1380
+ await probo.runStep(page, '${interaction.nativeDescription}', ${argument}, { stepId: ${interaction.stepId} });
1381
+ });`;
1382
+ }
1383
+ else if (interaction.action === PlaywrightAction.ASK_AI) {
1384
+ const escapedQuestion = interaction.nativeDescription.replace(/'/g, "\\'");
1385
+ const aiVarName = getReturnValueParameterName(interaction);
1386
+ // const assertion = interaction.argument ? `
1387
+ const askAIOptions = interaction.stepId ? `{ stepId: ${interaction.stepId} }` : '{}';
1388
+ return `${comment}
1389
+ await test.step("${escapedStepName}", async () => {
1390
+ const ${aiVarName} = await probo.askAI(page, '${escapedQuestion}', ${askAIOptions}${argument ? `, ${argument}` : ''});
1391
+ param['${aiVarName}'] = ${aiVarName};
1392
+ });`;
1393
+ }
1394
+ else if (interaction.action === PlaywrightAction.EXECUTE_SCRIPT) {
1395
+ return generateExecuteScriptCode(interaction, comment, escapedStepName);
1396
+ }
1397
+ else {
1398
+ return `${comment}
1399
+ await test.step("${escapedStepName}", async () => {
1400
+ ${argument ? `await probo.runStep(page, '${escapedPrompt}', ${argument}, { stepId: ${interaction.stepId} });` : `await probo.runStep(page, '${escapedPrompt}', null, { stepId: ${interaction.stepId} });`}
1401
+ });`;
1402
+ }
1403
+ }
1404
+ function generateExecuteScriptCode(interaction, comment, escapedStepName) {
1405
+ let scriptCode = Array.isArray(interaction.argument) ? interaction.argument[0] : interaction.argument;
1406
+ const scriptVarName = getReturnValueParameterName(interaction);
1407
+ // Replace returnValue(x) with 'const param = x;'
1408
+ scriptCode = scriptCode.replace(/returnValue\((.*)\);?/, `${scriptVarName} = $1;`);
1409
+ // Trim trailing whitespace
1410
+ scriptCode = scriptCode.trimEnd();
1411
+ // If scriptCode doesn't end with a semicolon add one
1412
+ if (!/\s*;\s*$/.test(scriptCode))
1413
+ scriptCode = scriptCode + ';';
1414
+ // Indent the code (extra indent for test.step wrapper)
1415
+ const indentedCode = scriptCode.split('\n').map((line, idx) => idx === 0 ? line : ` ${line}`).join('\n');
1416
+ return `${comment}
1417
+ await test.step("${escapedStepName}", async () => {
1418
+ let ${scriptVarName} = null;
1419
+ try {
1420
+ ${indentedCode}
1421
+ } catch (error) {
1422
+ console.error('❌ Workspace script execution failed:', error.message);
1423
+ throw error;
1424
+ }
1425
+ param['${scriptVarName}'] = ${scriptVarName};
1426
+ });`;
1427
+ }
1428
+ function scriptTemplate(options, settings, viewPort) {
1429
+ var _a, _b, _c, _d;
1430
+ const areActionsParameterized = options.rows.length > 0;
1431
+ const hasAiInteractions = options.interactions.some((interaction) => isAI(interaction));
1432
+ const hasScriptInteractions = options.interactions.some((interaction) => interaction.action === PlaywrightAction.EXECUTE_SCRIPT);
1433
+ // const uniquelyParameterizedInteractions = uniquifyInteractionParameters(options.interactions);
1434
+ const steps = options.interactions.map((interaction) => {
1435
+ if (isAI(interaction))
1436
+ return interactionToProboLib(interaction);
1437
+ return interactionToNativeCode(interaction);
1438
+ }).join('\n');
1439
+ // Get list of all extracted value parameter names to filter them out from the parameter table
1440
+ const extractedValueNames = new Set();
1441
+ options.interactions.forEach((interaction) => {
1442
+ if (hasReturnValue(interaction)) {
1443
+ const retName = getReturnValueParameterName(interaction);
1444
+ if (retName) {
1445
+ extractedValueNames.add(retName);
1446
+ }
1447
+ }
1448
+ });
1449
+ // Escape apostrophes and backslashes for single-quoted strings
1450
+ const escapeForSingleQuotes = (value) => {
1451
+ if (!value || typeof value !== 'string')
1452
+ return ''; //check for null, undefined, or non-string values
1453
+ //escape backslashes and single quotes
1454
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
1455
+ };
1456
+ // Lookup value from settings
1457
+ const aiModelKey = Object.keys(AIModel).find(key => AIModel[key] === settings.aiModel);
1458
+ // Check if any interactions have the isSecret flag set
1459
+ const hasSecrets = options.interactions.some(interaction => interaction.isSecret === true);
1460
+ // Extract required environment variable names from parameter table rows
1461
+ const requiredEnvVars = extractRequiredEnvVars(options.rows);
1462
+ const requiredEnvVarsArray = Array.from(requiredEnvVars).sort();
1463
+ // generate the parameter table (exclude extracted values as they are declared as const in the steps)
1464
+ const parameterTable = areActionsParameterized ? `
1465
+ const parameterTable: Record<string, any>[] = [
1466
+ ${options.rows.map((row) => {
1467
+ // Filter out extracted value columns from the parameter table
1468
+ const filteredRow = Object.fromEntries(Object.entries(row).filter(([key]) => !extractedValueNames.has(key)));
1469
+ return `{ ${Object.entries(filteredRow).map(([key, value]) => `${key}: '${escapeForSingleQuotes(value)}'`).join(', ')} }`;
1470
+ }).join(',\n ')}
1471
+ ];
1472
+ ` : '';
1473
+ const proboConstructor = hasAiInteractions ? `
1474
+ const probo = new Probo({
1475
+ scenarioName: '${options.scenarioName}',
1476
+ enableSmartSelectors: ${settings.enableSmartSelectors},
1477
+ debugLevel: ProboLogLevel.${settings.debugLevel},
1478
+ aiModel: AIModel.${aiModelKey},
1479
+ timeoutConfig: {
1480
+ waitForStabilityQuietTimeout: ${(_a = settings.waitForStabilityQuietTimeout) !== null && _a !== void 0 ? _a : 2000},
1481
+ waitForStabilityInitialDelay: ${(_b = settings.waitForStabilityInitialDelay) !== null && _b !== void 0 ? _b : 500},
1482
+ waitForStabilityGlobalTimeout: ${(_c = settings.waitForStabilityGlobalTimeout) !== null && _c !== void 0 ? _c : 15000},
1483
+ waitForStabilityVerbose: ${(_d = settings.waitForStabilityVerbose) !== null && _d !== void 0 ? _d : false}
1484
+ }
1485
+ });` : '';
1486
+ return `
1487
+ /*
1488
+ * Probo Labs Playwright Script
1489
+ * Scenario: ${options.scenarioName}
1490
+ * Auto generated on ${new Date().toLocaleDateString()} ${new Date().toLocaleTimeString()}
1491
+ *
1492
+ * HOW TO INTEGRATE THIS SCRIPT INTO YOUR EXISTING PROJECT:
1493
+ * 1. Install dependencies: npm install @playwright/test @probolabs/playwright@latest${hasSecrets ? ' dotenv' : ''}
1494
+ * 2. copy and paste the code below into your test file${hasSecrets ? '\n * 3. Ensure a .env file exists in your project root (or ~/.probium/.env) or set environment variables manually' : ''}
1495
+ */
1496
+
1497
+ // launch chromium with pre-installed extensions
1498
+ import { test, expect} from '@probolabs/playwright/fixtures';
1499
+ import { ProboPlaywright, Probo, PlaywrightAction, ProboLogLevel, AIModel } from '@probolabs/playwright';${hasScriptInteractions ? `
1500
+ import { execSync } from 'child_process';
1501
+ import * as fs from 'fs';
1502
+ import * as path from 'path';
1503
+ import * as os from 'os';` : ''}${hasSecrets && !hasScriptInteractions ? `
1504
+ import * as path from 'path';
1505
+ import * as os from 'os';
1506
+ import * as fs from 'fs';` : ''}
1507
+
1508
+ ${hasSecrets ? `import { config } from 'dotenv';
1509
+ // Load secrets: first try local .env (project root), then fallback to ~/.probium/.env
1510
+ const localEnvPath = path.join(process.cwd(), '.env');
1511
+ const homeEnvPath = path.join(os.homedir(), '.probium', '.env');
1512
+
1513
+ if (fs.existsSync(localEnvPath)) {
1514
+ config({ path: localEnvPath });
1515
+ console.log('✅ Loaded env vars from project .env:', localEnvPath);
1516
+ } else if (fs.existsSync(homeEnvPath)) {
1517
+ config({ path: homeEnvPath });
1518
+ console.log('✅ Loaded env vars from home .env:', homeEnvPath);
1519
+ } else {
1520
+ console.warn('⚠️ .env not found at', localEnvPath, 'or', homeEnvPath, '- ensure environment variables are set before running tests');
1521
+ }
1522
+
1523
+ // Validate that all required secrets are set
1524
+ const requiredSecrets: string[] = ${JSON.stringify(requiredEnvVarsArray)};
1525
+ const missingSecrets = requiredSecrets.filter(varName => !process.env[varName] || process.env[varName].trim() === '');
1526
+ if (missingSecrets.length > 0) {
1527
+ throw new Error(\`Missing required environment variables: \${missingSecrets.join(', ')}. Please set them in your .env file or as environment variables.\`);
1528
+ }
1529
+ console.log(\`✅ All required secrets are set: \${requiredSecrets.join(', ')}\`);
1530
+ ` : ''}
1531
+
1532
+ ${parameterTable}
1533
+ ${proboConstructor}
1534
+ const ppw = new ProboPlaywright({
1535
+ enableSmartSelectors: ${settings.enableSmartSelectors},
1536
+ debugLevel: ProboLogLevel.${settings.debugLevel},
1537
+ timeoutConfig: {
1538
+ waitForStabilityQuietTimeout: ${settings.waitForStabilityQuietTimeout},
1539
+ waitForStabilityInitialDelay: ${settings.waitForStabilityInitialDelay},
1540
+ waitForStabilityGlobalTimeout: ${settings.waitForStabilityGlobalTimeout},
1541
+ waitForStabilityVerbose: ${settings.waitForStabilityVerbose},
1542
+ highlightTimeout: ${settings.highlightTimeout},
1543
+ playwrightActionTimeout: ${settings.playwrightActionTimeout},
1544
+ playwrightNavigationTimeout: ${settings.playwrightNavigationTimeout},
1545
+ playwrightLocatorTimeout: ${settings.playwrightLocatorTimeout}
1546
+ }
1547
+ });
1548
+
1549
+ test.describe('${options.scenarioName}', () => {
1550
+ test.beforeEach(async ({ page }) => {
1551
+ // set the ProboPlaywright instance to the current page
1552
+ ppw.setPage(page);
1553
+ // set the viewport dimensions to be identical to their values during recording
1554
+ await page.setViewportSize({ width: ${viewPort.width}, height: ${viewPort.height} });
1555
+ });
1556
+
1557
+ for (const param of parameterTable) {
1558
+ test(param.testName, async ({ page }) => {
1559
+ ${hasAiInteractions ? `probo.setParams(param);` : ''}
1560
+ ppw.setParams(param);
1561
+ ${steps}
1562
+ });
1563
+ }
1564
+ });
1565
+ `;
1566
+ }
1567
+ /**
1568
+ * Generates Playwright code for a scenario
1569
+ * @param options - Code generation options (scenarioName, interactions, rows)
1570
+ * @param settings - Recorder settings (timeouts, AI model, etc.)
1571
+ * @param viewPort - Viewport dimensions {width, height}
1572
+ * @returns Generated Playwright test code as a string
1573
+ */
1574
+ function generateCode(options, settings, viewPort) {
1575
+ return scriptTemplate(options, settings, viewPort);
1576
+ }
1577
+ // Custom function to create a URL-friendly slug
1578
+ const createUrlSlug = (url) => {
1579
+ try {
1580
+ const urlObj = new URL(url);
1581
+ // Extract hostname and path
1582
+ const hostname = urlObj.hostname;
1583
+ const path = urlObj.pathname;
1584
+ // Create a more readable name
1585
+ let name = hostname;
1586
+ // Add path segments if they exist and are meaningful
1587
+ if (path && path !== '/') {
1588
+ // Remove leading slash and split by slashes
1589
+ const pathSegments = path.substring(1).split('/').filter(segment => segment);
1590
+ // Add meaningful path segments to the name
1591
+ if (pathSegments.length > 0) {
1592
+ // Take up to 2 path segments to keep the name reasonable
1593
+ const relevantSegments = pathSegments.slice(0, 2);
1594
+ name += '-' + relevantSegments.join('-');
1595
+ }
1596
+ }
1597
+ // Replace any remaining special characters with hyphens
1598
+ return name
1599
+ .toLowerCase()
1600
+ .replace(/[^a-z0-9-]/g, '-') // Replace non-alphanumeric chars with hyphens
1601
+ .replace(/-+/g, '-') // Replace multiple hyphens with a single hyphen
1602
+ .replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
1603
+ }
1604
+ catch (error) {
1605
+ // If URL parsing fails, create a simple slug
1606
+ // proboLog('❌ Error parsing URL:', error); // Don't log here, just fallback
1607
+ return url
1608
+ .toLowerCase()
1609
+ .replace(/[^a-z0-9-]/g, '-')
1610
+ .replace(/-+/g, '-')
1611
+ .replace(/^-+|-+$/g, '');
1612
+ }
1613
+ };
1614
+ /**
1615
+ * Generate package.json content for a Playwright test project
1616
+ */
1617
+ function generatePackageJson(options) {
1618
+ const { name, hasSecrets = false, testScript = 'npx playwright test' } = options;
1619
+ return `{
1620
+ "name": "${name}",
1621
+ "version": "1.0.0",
1622
+ "description": "Probo Labs Playwright Script",
1623
+ "type": "module",
1624
+ "scripts": {
1625
+ "test": "${testScript}"
1626
+ },
1627
+ "dependencies": {
1628
+ "@playwright/test": "^1.40.0",
1629
+ "@probolabs/playwright": "latest"${hasSecrets ? ',\n "dotenv": "latest"' : ''}
1630
+ }
1631
+ }`;
1632
+ }
1633
+ /**
1634
+ * Generate playwright.config.ts content
1635
+ */
1636
+ function generatePlaywrightConfig(includeReporter = true, runId) {
1637
+ const reporters = [
1638
+ ['list'],
1639
+ ['html', { open: 'never' }],
1640
+ ];
1641
+ // Configure run-specific output directories if runId is provided
1642
+ if (runId !== undefined) {
1643
+ reporters[1] = ['html', { open: 'never', outputFolder: `playwright-report/run-${runId}` }];
1644
+ }
1645
+ if (includeReporter) {
1646
+ reporters.push(['./probo-reporter.ts']);
1647
+ }
1648
+ // Configure outputDir for run-specific test results if runId is provided
1649
+ const outputDirConfig = runId !== undefined ? `\n outputDir: 'test-results/run-${runId}',` : '';
1650
+ return `import { defineConfig } from '@playwright/test';
1651
+
1652
+ export default defineConfig({
1653
+ testDir: 'tests',
1654
+ testMatch: /.*\\.(ts|js)$/,
1655
+ timeout: 600000,${outputDirConfig}
1656
+ // Keep Playwright's default console output, plus HTML report${includeReporter ? ', plus Probo live progress reporter' : ''}.
1657
+ reporter: ${JSON.stringify(reporters)},
1658
+ use: {
1659
+ headless: true,
1660
+ ignoreHTTPSErrors: true,
1661
+ actionTimeout: 30000,
1662
+ navigationTimeout: 30000,
1663
+ video: 'off',
1664
+ screenshot: 'on',
1665
+ trace: 'on',
1666
+ },
1667
+ retries: 0,
1668
+ workers: 1,
1669
+ });
1670
+ `;
1671
+ }
1672
+ /**
1673
+ * Generate a Playwright reporter that emits structured events to stdout.
1674
+ *
1675
+ * The recorder app runner can parse these lines and broadcast them to the UI.
1676
+ */
1677
+ function generateProboReporter() {
1678
+ // NOTE: Keep this file dependency-free; it runs inside the generated test suite project.
1679
+ // It prints one JSON payload per line, prefixed for easy extraction from stdout.
1680
+ return `import type { Reporter, FullConfig, Suite, TestCase, TestResult, TestStep } from '@playwright/test/reporter';
1681
+
1682
+ const PREFIX = '__PROBO_REPORTER_EVENT__';
1683
+
1684
+ type ProboReporterEvent =
1685
+ | {
1686
+ v: 1;
1687
+ ts: number;
1688
+ eventType: 'runBegin';
1689
+ config: { workers: number; retries: number; projects: string[] };
1690
+ }
1691
+ | {
1692
+ v: 1;
1693
+ ts: number;
1694
+ eventType: 'runEnd';
1695
+ status: 'passed' | 'failed' | 'timedout' | 'interrupted';
1696
+ }
1697
+ | {
1698
+ v: 1;
1699
+ ts: number;
1700
+ eventType: 'testBegin';
1701
+ test: ProboTestRef;
1702
+ }
1703
+ | {
1704
+ v: 1;
1705
+ ts: number;
1706
+ eventType: 'testEnd';
1707
+ test: ProboTestRef;
1708
+ result: {
1709
+ status: 'passed' | 'failed' | 'timedout' | 'skipped' | 'interrupted';
1710
+ expectedStatus: 'passed' | 'failed' | 'timedout' | 'skipped';
1711
+ duration: number;
1712
+ errors: string[];
1713
+ };
1714
+ }
1715
+ | {
1716
+ v: 1;
1717
+ ts: number;
1718
+ eventType: 'stepBegin';
1719
+ test: ProboTestRef;
1720
+ step: ProboStepRef & { title: string; category?: string; depth: number };
1721
+ }
1722
+ | {
1723
+ v: 1;
1724
+ ts: number;
1725
+ eventType: 'stepEnd';
1726
+ test: ProboTestRef;
1727
+ step: ProboStepRef & { duration?: number; error?: string | null };
1728
+ };
1729
+
1730
+ type ProboLocation = { file?: string; line?: number; column?: number };
1731
+ type ProboTestRef = {
1732
+ id?: string;
1733
+ title: string;
1734
+ titlePath: string[];
1735
+ location?: ProboLocation;
1079
1736
  };
1737
+ type ProboStepRef = {
1738
+ id: number;
1739
+ location?: ProboLocation;
1740
+ };
1741
+
1742
+ function safeLocation(loc: any): ProboLocation | undefined {
1743
+ if (!loc || typeof loc !== 'object') return undefined;
1744
+ const file = typeof loc.file === 'string' ? loc.file : undefined;
1745
+ const line = typeof loc.line === 'number' ? loc.line : undefined;
1746
+ const column = typeof loc.column === 'number' ? loc.column : undefined;
1747
+ return file || line || column ? { file, line, column } : undefined;
1748
+ }
1749
+
1750
+ function safeTitlePath(test: TestCase): string[] {
1751
+ try {
1752
+ // titlePath() exists on TestCase and includes describe blocks.
1753
+ return test.titlePath();
1754
+ } catch {
1755
+ return [test.title];
1756
+ }
1757
+ }
1758
+
1759
+ function testRef(test: TestCase): ProboTestRef {
1760
+ return {
1761
+ // @ts-expect-error: Playwright has test.id at runtime; keep optional for forward/back compat.
1762
+ id: (test as any).id,
1763
+ title: test.title,
1764
+ titlePath: safeTitlePath(test),
1765
+ location: safeLocation((test as any).location),
1766
+ };
1767
+ }
1768
+
1769
+ function serializeErrors(result: TestResult): string[] {
1770
+ const errors: any[] = (result as any).errors || [];
1771
+ if (!Array.isArray(errors)) return [];
1772
+ return errors
1773
+ .map((e) => {
1774
+ if (!e) return null;
1775
+ if (typeof e === 'string') return e;
1776
+ if (typeof e.message === 'string') return e.message;
1777
+ if (typeof e.value === 'string') return e.value;
1778
+ try {
1779
+ return JSON.stringify(e);
1780
+ } catch {
1781
+ return String(e);
1782
+ }
1783
+ })
1784
+ .filter(Boolean) as string[];
1785
+ }
1786
+
1787
+ function emit(event: ProboReporterEvent) {
1788
+ try {
1789
+ process.stdout.write(PREFIX + JSON.stringify(event) + '\\n');
1790
+ } catch {
1791
+ // Ignore reporter failures; never break the run.
1792
+ }
1793
+ }
1794
+
1795
+ /**
1796
+ * Check if a step should be included in the output.
1797
+ * Filters out hooks, fixtures, and internal Playwright operations.
1798
+ */
1799
+ function shouldIncludeStep(step: TestStep): boolean {
1800
+ const category = (step as any).category;
1801
+ const title = step.title?.toLowerCase() || '';
1802
+
1803
+ // Filter out hooks (beforeAll, beforeEach, afterAll, afterEach)
1804
+ if (category === 'hook') {
1805
+ return false;
1806
+ }
1807
+
1808
+ // Filter out fixtures
1809
+ if (category === 'fixture') {
1810
+ return false;
1811
+ }
1812
+
1813
+ // Filter out internal waiting/timeout operations
1814
+ if (title.includes('waiting for') ||
1815
+ title.includes('wait for') ||
1816
+ title.includes('timeout') ||
1817
+ title.includes('attaching') ||
1818
+ title.startsWith('attach')) {
1819
+ return false;
1820
+ }
1821
+
1822
+ // Include user-defined steps and assertions only (exclude pw:api like click, evaluate, etc.)
1823
+ return category === 'test.step' ||
1824
+ category === 'expect' ||
1825
+ !category; // Include steps without category (likely user actions)
1826
+ }
1827
+
1828
+ export default class ProboReporter implements Reporter {
1829
+ private stepIds = new WeakMap<TestStep, number>();
1830
+ private stepDepths = new WeakMap<TestStep, number>();
1831
+ private nextStepId = 1;
1832
+
1833
+ onBegin(config: FullConfig, suite: Suite) {
1834
+ emit({
1835
+ v: 1,
1836
+ ts: Date.now(),
1837
+ eventType: 'runBegin',
1838
+ config: {
1839
+ workers: config.workers,
1840
+ retries: config.retries,
1841
+ projects: (config.projects || []).map((p) => p.name || 'project'),
1842
+ },
1843
+ });
1844
+ }
1845
+
1846
+ onEnd(result: any) {
1847
+ emit({
1848
+ v: 1,
1849
+ ts: Date.now(),
1850
+ eventType: 'runEnd',
1851
+ status: result?.status || 'failed',
1852
+ });
1853
+ }
1854
+
1855
+ onTestBegin(test: TestCase, result: TestResult) {
1856
+ emit({
1857
+ v: 1,
1858
+ ts: Date.now(),
1859
+ eventType: 'testBegin',
1860
+ test: testRef(test),
1861
+ });
1862
+ }
1863
+
1864
+ onTestEnd(test: TestCase, result: TestResult) {
1865
+ emit({
1866
+ v: 1,
1867
+ ts: Date.now(),
1868
+ eventType: 'testEnd',
1869
+ test: testRef(test),
1870
+ result: {
1871
+ status: result.status,
1872
+ expectedStatus: result.expectedStatus,
1873
+ duration: result.duration,
1874
+ errors: serializeErrors(result),
1875
+ },
1876
+ });
1877
+ }
1878
+
1879
+ onStepBegin(test: TestCase, result: TestResult, step: TestStep) {
1880
+ // Filter out hooks, fixtures, and internal operations
1881
+ if (!shouldIncludeStep(step)) {
1882
+ return;
1883
+ }
1884
+
1885
+ const id = this.stepIds.get(step) ?? this.nextStepId++;
1886
+ this.stepIds.set(step, id);
1887
+
1888
+ const depth = this.stepDepths.get(step) ?? (step.parent ? (this.stepDepths.get(step.parent) ?? 0) + 1 : 0);
1889
+ this.stepDepths.set(step, depth);
1890
+
1891
+ emit({
1892
+ v: 1,
1893
+ ts: Date.now(),
1894
+ eventType: 'stepBegin',
1895
+ test: testRef(test),
1896
+ step: {
1897
+ id,
1898
+ title: step.title,
1899
+ category: (step as any).category,
1900
+ depth,
1901
+ location: safeLocation((step as any).location),
1902
+ },
1903
+ });
1904
+ }
1905
+
1906
+ onStepEnd(test: TestCase, result: TestResult, step: TestStep) {
1907
+ // Filter out hooks, fixtures, and internal operations
1908
+ if (!shouldIncludeStep(step)) {
1909
+ return;
1910
+ }
1911
+
1912
+ const id = this.stepIds.get(step) ?? this.nextStepId++;
1913
+ this.stepIds.set(step, id);
1914
+
1915
+ const err = (step as any).error;
1916
+ emit({
1917
+ v: 1,
1918
+ ts: Date.now(),
1919
+ eventType: 'stepEnd',
1920
+ test: testRef(test),
1921
+ step: {
1922
+ id,
1923
+ duration: (step as any).duration,
1924
+ error: err ? (typeof err.message === 'string' ? err.message : String(err)) : null,
1925
+ location: safeLocation((step as any).location),
1926
+ },
1927
+ });
1928
+ }
1929
+ }
1930
+ `;
1931
+ }
1080
1932
 
1081
1933
  // Default logger instance
1082
1934
  const proboLogger = new ProboLogger('proboLib');
@@ -4018,6 +4870,7 @@ class ProboPlaywright {
4018
4870
  ...timeoutConfig
4019
4871
  };
4020
4872
  this.isCanceled = isCanceled || null;
4873
+ this.highlighter = new Highlighter(enableSmartSelectors, false, debugLevel);
4021
4874
  this.setPage(page);
4022
4875
  proboLogger.setLogLevel(debugLevel);
4023
4876
  }
@@ -4085,6 +4938,13 @@ class ProboPlaywright {
4085
4938
  if (action === PlaywrightAction.VISIT_URL || action === PlaywrightAction.VISIT_BASE_URL) {
4086
4939
  try {
4087
4940
  await this.page.goto(argument, { timeout: this.timeoutConfig.playwrightNavigationTimeout });
4941
+ const navTracker = await NavTracker.getInstance(this.page, {
4942
+ waitForStabilityQuietTimeout: this.timeoutConfig.waitForStabilityQuietTimeout,
4943
+ waitForStabilityInitialDelay: this.timeoutConfig.waitForStabilityInitialDelay,
4944
+ waitForStabilityGlobalTimeout: this.timeoutConfig.waitForStabilityGlobalTimeout,
4945
+ waitForStabilityVerbose: this.timeoutConfig.waitForStabilityVerbose
4946
+ });
4947
+ await navTracker.waitForNavigationToStabilize();
4088
4948
  }
4089
4949
  catch (e) {
4090
4950
  throw new Error(`Failed to navigate to ${argument}`);
@@ -4107,6 +4967,20 @@ class ProboPlaywright {
4107
4967
  await this.robustTypeKeys(argument);
4108
4968
  return;
4109
4969
  }
4970
+ // Capture base screenshot before element interaction (if screenshot function is provided)
4971
+ // Only capture base screenshot during apply-ai, skip during replay
4972
+ let baseScreenshotUrl = null;
4973
+ if (params.takeScreenshot && params.isApplyAIContext) {
4974
+ try {
4975
+ proboLogger.debug('Capturing base screenshot before element interaction');
4976
+ baseScreenshotUrl = await params.takeScreenshot(this.page, 'base');
4977
+ proboLogger.debug(`Base screenshot uploaded: ${baseScreenshotUrl}`);
4978
+ }
4979
+ catch (baseError) {
4980
+ proboLogger.warn(`Failed to capture base screenshot: ${baseError}`);
4981
+ // Continue even if base screenshot fails
4982
+ }
4983
+ }
4110
4984
  let locator;
4111
4985
  if (iframeSelector && iframeSelector.length > 0) {
4112
4986
  const frameLocator = await this.getLocator(iframeSelector, smartIFrameSelector, true);
@@ -4182,11 +5056,74 @@ class ProboPlaywright {
4182
5056
  locator = visibleLocator;
4183
5057
  }
4184
5058
  }
4185
- // 5. Highlight, wait, unhighlight if highlightTimeout > 0
4186
- if (this.timeoutConfig.highlightTimeout > 0) {
4187
- await this.highlight(locator, annotation);
4188
- await this.page.waitForTimeout(this.timeoutConfig.highlightTimeout);
4189
- await this.unhighlight(locator);
5059
+ // 5. Capture screenshots if screenshot function is provided
5060
+ const screenshotUrls = {
5061
+ base_screenshot_url: baseScreenshotUrl
5062
+ };
5063
+ if (params.takeScreenshot) {
5064
+ try {
5065
+ // Candidate elements screenshot: find and highlight candidates based on action type
5066
+ // Only capture candidate screenshot during apply-ai, skip during replay
5067
+ if (params.isApplyAIContext) {
5068
+ try {
5069
+ const elementTags = resolveElementTag(action);
5070
+ proboLogger.debug(`Finding candidate elements for action ${action} with tags: ${elementTags}`);
5071
+ await this.highlighter.findAndCacheCandidateElements(this.page, elementTags);
5072
+ await this.highlighter.highlightCachedElements(this.page, 'candidates');
5073
+ // Wait for highlight timeout to ensure highlights are visible
5074
+ // if (this.timeoutConfig.highlightTimeout > 0) {
5075
+ // await this.page.waitForTimeout(this.timeoutConfig.highlightTimeout);
5076
+ // }
5077
+ screenshotUrls.candidates_screenshot_url = await params.takeScreenshot(this.page, 'candidates');
5078
+ proboLogger.debug(`Candidates screenshot uploaded: ${screenshotUrls.candidates_screenshot_url}`);
5079
+ // Unhighlight candidates
5080
+ await this.highlighter.unhighlightCached(this.page);
5081
+ }
5082
+ catch (candidateError) {
5083
+ proboLogger.warn(`Failed to capture candidates screenshot: ${candidateError}`);
5084
+ // Continue even if candidate screenshot fails
5085
+ }
5086
+ }
5087
+ // Actual element screenshot: highlight the actual element
5088
+ // Always capture actual screenshot for both apply-ai and replay
5089
+ try {
5090
+ await this.highlight(locator, annotation);
5091
+ // Wait for highlight timeout to ensure highlights are visible
5092
+ if (this.timeoutConfig.highlightTimeout > 0) {
5093
+ await this.page.waitForTimeout(this.timeoutConfig.highlightTimeout);
5094
+ }
5095
+ screenshotUrls.actual_interaction_screenshot_url = await params.takeScreenshot(this.page, 'actual');
5096
+ proboLogger.debug(`Actual element screenshot uploaded: ${screenshotUrls.actual_interaction_screenshot_url}`);
5097
+ // Unhighlight before performing action
5098
+ await this.unhighlight(locator);
5099
+ }
5100
+ catch (actualError) {
5101
+ proboLogger.warn(`Failed to capture actual element screenshot: ${actualError}`);
5102
+ // Try to unhighlight even if screenshot failed
5103
+ try {
5104
+ await this.unhighlight(locator);
5105
+ }
5106
+ catch (unhighlightError) {
5107
+ proboLogger.warn(`Failed to unhighlight: ${unhighlightError}`);
5108
+ }
5109
+ }
5110
+ // Call the callback with screenshot URLs
5111
+ if (params.onScreenshots) {
5112
+ params.onScreenshots(screenshotUrls);
5113
+ }
5114
+ }
5115
+ catch (screenshotError) {
5116
+ proboLogger.warn(`Screenshot capture failed: ${screenshotError}`);
5117
+ // Continue with action even if screenshots fail
5118
+ }
5119
+ }
5120
+ else {
5121
+ // 5. Highlight, wait, unhighlight if highlightTimeout > 0 (original behavior when no screenshots)
5122
+ if (this.timeoutConfig.highlightTimeout > 0) {
5123
+ await this.highlight(locator, annotation);
5124
+ await this.page.waitForTimeout(this.timeoutConfig.highlightTimeout);
5125
+ await this.unhighlight(locator);
5126
+ }
4190
5127
  }
4191
5128
  // 6. Action logic
4192
5129
  switch (action) {
@@ -4273,18 +5210,40 @@ class ProboPlaywright {
4273
5210
  case PlaywrightAction.SCROLL_TO_ELEMENT:
4274
5211
  // Restore exact scroll positions from recording
4275
5212
  const scrollData = JSON.parse(argument);
5213
+ const desiredScrollTop = scrollData.scrollTop;
5214
+ const desiredScrollLeft = scrollData.scrollLeft;
5215
+ const maxAttempts = 100;
4276
5216
  try {
4277
- proboLogger.log('🔄 Restoring scroll position for container:', locator, 'scrollTop:', scrollData.scrollTop, 'scrollLeft:', scrollData.scrollLeft);
4278
- await locator.evaluate((el, scrollData) => {
4279
- // el.scrollTop = scrollData.scrollTop;
4280
- // el.scrollLeft = scrollData.scrollLeft;
4281
- el.scrollTo({ left: scrollData.scrollLeft, top: scrollData.scrollTop, behavior: 'smooth' });
4282
- }, { scrollTop: scrollData.scrollTop, scrollLeft: scrollData.scrollLeft }, { timeout: this.timeoutConfig.playwrightActionTimeout });
5217
+ proboLogger.log('🔄 Restoring scroll position for container:', locator, 'scrollTop:', desiredScrollTop, 'scrollLeft:', desiredScrollLeft);
5218
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
5219
+ // Perform scroll
5220
+ await locator.evaluate((el, scrollData) => {
5221
+ el.scrollTo({ left: scrollData.scrollLeft, top: scrollData.scrollTop, behavior: 'smooth' });
5222
+ }, { scrollTop: desiredScrollTop, scrollLeft: desiredScrollLeft }, { timeout: this.timeoutConfig.playwrightActionTimeout });
5223
+ // Wait for scroll to complete
5224
+ await this.page.waitForTimeout(50);
5225
+ // Get actual scroll positions
5226
+ const actualScroll = await locator.evaluate((el) => {
5227
+ return { scrollTop: el.scrollTop, scrollLeft: el.scrollLeft };
5228
+ }, { timeout: this.timeoutConfig.playwrightActionTimeout });
5229
+ // Compare actual vs desired
5230
+ const scrollTopMatch = Math.abs(actualScroll.scrollTop - desiredScrollTop) < 1;
5231
+ const scrollLeftMatch = Math.abs(actualScroll.scrollLeft - desiredScrollLeft) < 1;
5232
+ if (scrollTopMatch && scrollLeftMatch) {
5233
+ proboLogger.log(`🔄 Scroll position restored successfully on attempt ${attempt}`);
5234
+ break;
5235
+ }
5236
+ if (attempt < maxAttempts) {
5237
+ proboLogger.log(`🔄 Scroll position mismatch on attempt ${attempt}. Desired: (${desiredScrollTop}, ${desiredScrollLeft}), Actual: (${actualScroll.scrollTop}, ${actualScroll.scrollLeft}). Retrying...`);
5238
+ }
5239
+ else {
5240
+ proboLogger.warn(`🔄 Scroll position mismatch after ${maxAttempts} attempts. Desired: (${desiredScrollTop}, ${desiredScrollLeft}), Final: (${actualScroll.scrollTop}, ${actualScroll.scrollLeft})`);
5241
+ }
5242
+ }
4283
5243
  }
4284
5244
  catch (e) {
4285
- proboLogger.error('🔄 Failed to restore scroll position for container:', locator, 'scrollTop:', scrollData.scrollTop, 'scrollLeft:', scrollData.scrollLeft, 'error:', e);
5245
+ proboLogger.error('🔄 Failed to restore scroll position for container:', locator, 'scrollTop:', desiredScrollTop, 'scrollLeft:', desiredScrollLeft, 'error:', e);
4286
5246
  }
4287
- await this.page.waitForTimeout(500);
4288
5247
  break;
4289
5248
  case PlaywrightAction.UPLOAD_FILES:
4290
5249
  await locator.setInputFiles(argument, { timeout: this.timeoutConfig.playwrightActionTimeout });
@@ -4750,6 +5709,866 @@ class ProboPlaywright {
4750
5709
  }
4751
5710
  } /* class ProboPlaywright */
4752
5711
 
5712
+ // --- Code generation API utilities for Probo Labs Playwright scripts ---
5713
+ /**
5714
+ * ProboCodeGenerator - Handles fetching test suite/scenario data and generating Playwright code
5715
+ */
5716
+ class ProboCodeGenerator {
5717
+ /**
5718
+ * Normalize API URL by removing trailing slash
5719
+ */
5720
+ static normalizeApiUrl(apiUrl) {
5721
+ return apiUrl.replace(/\/+$/, '');
5722
+ }
5723
+ static async readResponseErrorText(response) {
5724
+ try {
5725
+ const contentType = response.headers.get('content-type');
5726
+ if (contentType && contentType.includes('application/json')) {
5727
+ const errorData = await response.json();
5728
+ return errorData.error || errorData.detail || JSON.stringify(errorData);
5729
+ }
5730
+ let errorText = await response.text();
5731
+ // Truncate HTML error pages to first 200 chars
5732
+ if (errorText.length > 200) {
5733
+ errorText = errorText.substring(0, 200) + '...';
5734
+ }
5735
+ return errorText;
5736
+ }
5737
+ catch (e) {
5738
+ // Best-effort fallback
5739
+ return `HTTP ${response.status}`;
5740
+ }
5741
+ }
5742
+ /**
5743
+ * Fetch scenario data from the backend API
5744
+ */
5745
+ static async fetchScenarioData(scenarioId, apiToken, apiUrl) {
5746
+ const normalizedUrl = this.normalizeApiUrl(apiUrl);
5747
+ const url = `${normalizedUrl}/api/scenarios/${scenarioId}/interactions`;
5748
+ const response = await fetch(url, {
5749
+ method: 'GET',
5750
+ headers: {
5751
+ 'Authorization': `Token ${apiToken}`,
5752
+ 'Content-Type': 'application/json',
5753
+ },
5754
+ });
5755
+ if (!response.ok) {
5756
+ const errorText = await this.readResponseErrorText(response);
5757
+ throw new Error(`Failed to fetch scenario ${scenarioId}: ${response.status} ${errorText}`);
5758
+ }
5759
+ return response.json();
5760
+ }
5761
+ /**
5762
+ * Fetch test suite data from the backend API
5763
+ */
5764
+ static async fetchTestSuiteData(testSuiteId, apiToken, apiUrl) {
5765
+ const normalizedUrl = this.normalizeApiUrl(apiUrl);
5766
+ // Note: test-suites endpoint doesn't have /api/ prefix (it's from the router at root level)
5767
+ const url = `${normalizedUrl}/test-suites/${testSuiteId}/`;
5768
+ const response = await fetch(url, {
5769
+ method: 'GET',
5770
+ headers: {
5771
+ 'Authorization': `Token ${apiToken}`,
5772
+ 'Content-Type': 'application/json',
5773
+ },
5774
+ });
5775
+ if (!response.ok) {
5776
+ const errorText = await this.readResponseErrorText(response);
5777
+ throw new Error(`Failed to fetch test suite ${testSuiteId}: ${response.status} ${errorText}`);
5778
+ }
5779
+ return response.json();
5780
+ }
5781
+ /**
5782
+ * Convert backend interaction format to Interaction[] format
5783
+ */
5784
+ static convertBackendInteractionsToInteractionFormat(backendInteractions) {
5785
+ return backendInteractions.map((backendInteraction) => {
5786
+ // Convert action string to PlaywrightAction enum
5787
+ const action = backendInteraction.action;
5788
+ // Convert apply_ai_status string to enum if present
5789
+ let applyAiStatus = null;
5790
+ if (backendInteraction.apply_ai_status) {
5791
+ applyAiStatus = backendInteraction.apply_ai_status;
5792
+ }
5793
+ // Build elementInfo from backend data
5794
+ const elementInfo = backendInteraction.elementInfo || null;
5795
+ // Build interaction object
5796
+ const interaction = {
5797
+ interactionId: backendInteraction.interactionId,
5798
+ action: action,
5799
+ argument: backendInteraction.argument || '',
5800
+ elementInfo: elementInfo,
5801
+ iframe_selector: backendInteraction.iframe_selector,
5802
+ css_selector: backendInteraction.css_selector,
5803
+ smart_selector: backendInteraction.smart_selector || undefined,
5804
+ smart_iframe_selector: backendInteraction.smart_iframe_selector || undefined,
5805
+ url: backendInteraction.url,
5806
+ timestamp: backendInteraction.last_replay_timestamp || Date.now(),
5807
+ base_screenshot_url: backendInteraction.base_screenshot_url,
5808
+ actual_interaction_screenshot_url: backendInteraction.actual_interaction_screenshot_url,
5809
+ candidates_screenshot_url: backendInteraction.candidates_screenshot_url,
5810
+ candidate_elements: backendInteraction.candidate_elements || [],
5811
+ recordingId: backendInteraction.recordingId,
5812
+ syncStatus: backendInteraction.syncStatus,
5813
+ serverResponse: backendInteraction.serverResponse || null,
5814
+ nativeName: backendInteraction.nativeName,
5815
+ nativeDescription: backendInteraction.nativeDescription,
5816
+ isNativeDescriptionElaborate: backendInteraction.isNativeDescriptionElaborate,
5817
+ parameterName: backendInteraction.parameterName,
5818
+ annotation: backendInteraction.annotation,
5819
+ last_replay_timestamp: backendInteraction.last_replay_timestamp,
5820
+ replay_status: backendInteraction.replay_status,
5821
+ apply_ai_status: applyAiStatus,
5822
+ error: null,
5823
+ index: backendInteraction.index,
5824
+ html: backendInteraction.html,
5825
+ stepId: backendInteraction.stepId,
5826
+ scrollableContainers: backendInteraction.scrollableContainers,
5827
+ stdout: undefined,
5828
+ waitForConfig: backendInteraction.waitForConfig,
5829
+ totpConfig: backendInteraction.totpConfig,
5830
+ isSecret: backendInteraction.isSecret || false,
5831
+ };
5832
+ return interaction;
5833
+ });
5834
+ }
5835
+ /**
5836
+ * Get default recorder settings for code generation
5837
+ */
5838
+ static getDefaultRecorderSettings() {
5839
+ return {
5840
+ ...DEFAULT_RECORDER_SETTINGS,
5841
+ // Override with sensible defaults for code generation
5842
+ enableSmartSelectors: true,
5843
+ debugLevel: 'INFO',
5844
+ enableConsoleLogs: false,
5845
+ };
5846
+ }
5847
+ /**
5848
+ * Get default viewport dimensions
5849
+ */
5850
+ static getDefaultViewPort() {
5851
+ return { width: 1280, height: 720 };
5852
+ }
5853
+ /**
5854
+ * Generate code for a single scenario
5855
+ * Fetches scenario data, converts to Interaction[], generates code
5856
+ */
5857
+ static async generateCodeForScenario(scenarioId, apiToken, apiUrl, options) {
5858
+ // Validate inputs
5859
+ if (!apiToken) {
5860
+ throw new Error('API token is required');
5861
+ }
5862
+ if (!apiUrl) {
5863
+ throw new Error('API URL is required');
5864
+ }
5865
+ // Fetch scenario data
5866
+ const scenarioData = await this.fetchScenarioData(scenarioId, apiToken, apiUrl);
5867
+ // Convert backend interactions to Interaction[] format
5868
+ const interactions = this.convertBackendInteractionsToInteractionFormat(scenarioData.interactions || []);
5869
+ // Get settings and viewport
5870
+ const settings = (options === null || options === void 0 ? void 0 : options.recorderSettings) || this.getDefaultRecorderSettings();
5871
+ const viewPort = (options === null || options === void 0 ? void 0 : options.viewPort) || scenarioData.viewPort || this.getDefaultViewPort();
5872
+ // Get parameter table rows
5873
+ const rows = scenarioData.parameterTable || [];
5874
+ // Generate code
5875
+ const codeOptions = {
5876
+ scenarioName: scenarioData.name,
5877
+ interactions: interactions,
5878
+ rows: rows,
5879
+ };
5880
+ return generateCode(codeOptions, settings, viewPort);
5881
+ }
5882
+ /**
5883
+ * Generate code for all scenarios in a test suite
5884
+ * Returns map of scenario names to generated code
5885
+ */
5886
+ static async generateCodeForTestSuite(testSuiteId, apiToken, apiUrl, options) {
5887
+ // Validate inputs
5888
+ if (!apiToken) {
5889
+ throw new Error('API token is required');
5890
+ }
5891
+ if (!apiUrl) {
5892
+ throw new Error('API URL is required');
5893
+ }
5894
+ // Fetch test suite data
5895
+ const testSuiteData = await this.fetchTestSuiteData(testSuiteId, apiToken, apiUrl);
5896
+ // Generate code for each scenario
5897
+ const scenarioResults = await Promise.all(testSuiteData.scenarios.map(async (scenario) => {
5898
+ try {
5899
+ const code = await this.generateCodeForScenario(scenario.id, apiToken, apiUrl, options);
5900
+ return {
5901
+ scenarioId: scenario.id,
5902
+ scenarioName: scenario.name,
5903
+ code: code,
5904
+ };
5905
+ }
5906
+ catch (error) {
5907
+ // Log error but continue with other scenarios
5908
+ console.error(`Failed to generate code for scenario ${scenario.id}: ${error.message}`);
5909
+ throw error; // Re-throw to fail fast for now
5910
+ }
5911
+ }));
5912
+ return {
5913
+ testSuiteId: testSuiteData.id,
5914
+ testSuiteName: testSuiteData.name,
5915
+ scenarios: scenarioResults,
5916
+ };
5917
+ }
5918
+ }
5919
+
5920
+ const execAsync = promisify(exec);
5921
+ /**
5922
+ * Ensures a directory exists, creating it recursively if needed
5923
+ */
5924
+ function ensureDirectoryExists(dir) {
5925
+ if (!fs.existsSync(dir)) {
5926
+ fs.mkdirSync(dir, { recursive: true });
5927
+ }
5928
+ }
5929
+ /**
5930
+ * Gets the default test suite directory path
5931
+ */
5932
+ function getDefaultTestSuiteDir(testSuiteId, testSuiteName) {
5933
+ const baseDir = path.join(os.homedir(), '.probium', 'test-suites');
5934
+ if (testSuiteName) {
5935
+ const sanitizedName = slugify(testSuiteName);
5936
+ return path.join(baseDir, `${testSuiteId}-${sanitizedName}`);
5937
+ }
5938
+ return path.join(baseDir, testSuiteId.toString());
5939
+ }
5940
+ /**
5941
+ * Count total tests by scanning spec files
5942
+ */
5943
+ function countTotalTests(testSuiteDir) {
5944
+ const testsDir = path.join(testSuiteDir, 'tests');
5945
+ if (!fs.existsSync(testsDir)) {
5946
+ return 0;
5947
+ }
5948
+ let totalTests = 0;
5949
+ const specFiles = fs.readdirSync(testsDir).filter(f => f.endsWith('.spec.ts'));
5950
+ for (const specFile of specFiles) {
5951
+ const filePath = path.join(testsDir, specFile);
5952
+ const content = fs.readFileSync(filePath, 'utf-8');
5953
+ // Count test() and it() calls
5954
+ const testMatches = content.match(/\btest\s*\(/g);
5955
+ const itMatches = content.match(/\bit\s*\(/g);
5956
+ if (testMatches)
5957
+ totalTests += testMatches.length;
5958
+ if (itMatches)
5959
+ totalTests += itMatches.length;
5960
+ // If no matches, assume at least 1 test per file
5961
+ if (!testMatches && !itMatches) {
5962
+ totalTests += 1;
5963
+ }
5964
+ }
5965
+ return totalTests;
5966
+ }
5967
+ /**
5968
+ * Parse Playwright statistics from stdout
5969
+ */
5970
+ function parsePlaywrightStatistics(stdout) {
5971
+ // Try to find final summary line: "X passed" or "X passed, Y failed" etc.
5972
+ const summaryMatch = stdout.match(/(\d+)\s+passed(?:,\s*(\d+)\s+failed)?(?:,\s*(\d+)\s+skipped)?/);
5973
+ if (summaryMatch) {
5974
+ return {
5975
+ passed: parseInt(summaryMatch[1]) || 0,
5976
+ failed: parseInt(summaryMatch[2]) || 0,
5977
+ skipped: parseInt(summaryMatch[3]) || 0,
5978
+ };
5979
+ }
5980
+ // Fallback: count individual test results
5981
+ const passed = (stdout.match(/✓/g) || []).length;
5982
+ const failed = (stdout.match(/✘/g) || []).length;
5983
+ const skipped = (stdout.match(/-\s+test/g) || []).length;
5984
+ return { passed, failed, skipped };
5985
+ }
5986
+ /**
5987
+ * Parse statistics from Playwright HTML report metadata (most reliable)
5988
+ */
5989
+ function parseStatisticsFromMetadata(testSuiteDir) {
5990
+ try {
5991
+ const reportDir = path.join(testSuiteDir, 'playwright-report');
5992
+ const metadataPath = path.join(reportDir, 'data', 'metadata.json');
5993
+ if (!fs.existsSync(metadataPath)) {
5994
+ return null;
5995
+ }
5996
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
5997
+ return {
5998
+ passed: metadata.passed || 0,
5999
+ failed: metadata.failed || 0,
6000
+ skipped: metadata.skipped || 0,
6001
+ };
6002
+ }
6003
+ catch (error) {
6004
+ console.warn('⚠️ Could not parse metadata.json:', error);
6005
+ return null;
6006
+ }
6007
+ }
6008
+ /**
6009
+ * Update run status via API
6010
+ */
6011
+ async function updateRunStatus(runId, testSuiteId, updates, apiToken, apiUrl) {
6012
+ // Ensure apiUrl doesn't have trailing slash
6013
+ const baseUrl = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl;
6014
+ const url = `${baseUrl}/test-suites/${testSuiteId}/runs/${runId}/`;
6015
+ const body = JSON.stringify(updates);
6016
+ const headers = {
6017
+ 'Authorization': `Token ${apiToken}`,
6018
+ 'Content-Type': 'application/json',
6019
+ };
6020
+ try {
6021
+ const response = await fetch$1(url, {
6022
+ method: 'PATCH',
6023
+ headers: headers,
6024
+ body: body,
6025
+ });
6026
+ if (!response.ok) {
6027
+ const errorText = await response.text();
6028
+ console.warn(`⚠️ Failed to update run status: ${response.status} ${errorText}`);
6029
+ }
6030
+ }
6031
+ catch (error) {
6032
+ console.error(`❌ Error updating run status:`, error);
6033
+ }
6034
+ }
6035
+ /**
6036
+ * TestSuiteRunner - Handles test suite file generation and execution
6037
+ */
6038
+ class TestSuiteRunner {
6039
+ /**
6040
+ * Lookup test suite ID by name and project
6041
+ */
6042
+ static async lookupTestSuiteByName(testSuiteName, projectName, apiToken, apiUrl) {
6043
+ const baseUrl = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl;
6044
+ const url = `${baseUrl}/test-suites/?name=${encodeURIComponent(testSuiteName)}&project=${encodeURIComponent(projectName)}`;
6045
+ const response = await fetch$1(url, {
6046
+ method: 'GET',
6047
+ headers: {
6048
+ 'Authorization': `Token ${apiToken}`,
6049
+ 'Content-Type': 'application/json',
6050
+ },
6051
+ });
6052
+ if (!response.ok) {
6053
+ const errorText = await response.text();
6054
+ throw new Error(`Failed to lookup test suite: ${response.status} ${errorText}`);
6055
+ }
6056
+ const data = await response.json();
6057
+ if (Array.isArray(data) && data.length > 0) {
6058
+ return data[0].id;
6059
+ }
6060
+ if (data.results && Array.isArray(data.results) && data.results.length > 0) {
6061
+ return data.results[0].id;
6062
+ }
6063
+ throw new Error(`Test suite "${testSuiteName}" not found in project "${projectName}"`);
6064
+ }
6065
+ /**
6066
+ * Generate all files for a test suite
6067
+ */
6068
+ static async generateTestSuiteFiles(testSuiteId, apiToken, apiUrl, outputDir, testSuiteName, includeReporter = true, runId) {
6069
+ const testSuiteDir = outputDir || getDefaultTestSuiteDir(testSuiteId, testSuiteName);
6070
+ // Generate code for all scenarios
6071
+ const codeGenResult = await ProboCodeGenerator.generateCodeForTestSuite(testSuiteId, apiToken, apiUrl);
6072
+ // Ensure directories exist
6073
+ ensureDirectoryExists(testSuiteDir);
6074
+ const testsDir = path.join(testSuiteDir, 'tests');
6075
+ ensureDirectoryExists(testsDir);
6076
+ // Save each scenario's code to a .spec.ts file
6077
+ for (const scenario of codeGenResult.scenarios) {
6078
+ const fileName = `${slugify(scenario.scenarioName)}.spec.ts`;
6079
+ const filePath = path.join(testsDir, fileName);
6080
+ fs.writeFileSync(filePath, scenario.code, 'utf-8');
6081
+ console.log(`✅ Generated test file: ${filePath}`);
6082
+ }
6083
+ // Generate package.json
6084
+ await this.generatePackageJson(testSuiteDir, codeGenResult);
6085
+ // Generate playwright.config.ts with runId if available
6086
+ await this.generatePlaywrightConfig(testSuiteDir, includeReporter, runId);
6087
+ // Generate custom reporter file for live progress streaming (only if requested)
6088
+ if (includeReporter) {
6089
+ await this.generateProboReporter(testSuiteDir);
6090
+ }
6091
+ }
6092
+ /**
6093
+ * Generate package.json file
6094
+ */
6095
+ static async generatePackageJson(outputDir, codeGenResult) {
6096
+ // Check if any scenario has secrets by examining the generated code
6097
+ // We'll check for dotenv imports in the generated code
6098
+ const hasSecrets = codeGenResult.scenarios.some(scenario => scenario.code.includes("import { config } from 'dotenv'"));
6099
+ const sanitizedName = slugify(codeGenResult.testSuiteName);
6100
+ const packageJsonContent = generatePackageJson({
6101
+ name: sanitizedName,
6102
+ hasSecrets: hasSecrets,
6103
+ testScript: 'npx playwright test'
6104
+ });
6105
+ const filePath = path.join(outputDir, 'package.json');
6106
+ fs.writeFileSync(filePath, packageJsonContent, 'utf-8');
6107
+ console.log(`✅ Generated package.json: ${filePath}`);
6108
+ }
6109
+ /**
6110
+ * Generate playwright.config.ts file
6111
+ */
6112
+ static async generatePlaywrightConfig(outputDir, includeReporter = true, runId) {
6113
+ const configContent = generatePlaywrightConfig(includeReporter, runId);
6114
+ const filePath = path.join(outputDir, 'playwright.config.ts');
6115
+ fs.writeFileSync(filePath, configContent, 'utf-8');
6116
+ console.log(`✅ Generated playwright.config.ts: ${filePath}`);
6117
+ }
6118
+ /**
6119
+ * Generate Probo custom Playwright reporter (for live step/test events)
6120
+ */
6121
+ static async generateProboReporter(outputDir) {
6122
+ const reporterContent = generateProboReporter();
6123
+ const filePath = path.join(outputDir, 'probo-reporter.ts');
6124
+ fs.writeFileSync(filePath, reporterContent, 'utf-8');
6125
+ console.log(`✅ Generated probo-reporter.ts: ${filePath}`);
6126
+ }
6127
+ /**
6128
+ * Run a test suite
6129
+ * Generates files, installs dependencies, and executes tests
6130
+ */
6131
+ static async runTestSuite(testSuiteId, apiToken, apiUrl, testSuiteName, runId, options = {}) {
6132
+ const { outputDir, includeReporter = true, onStatusUpdate, onStdout, onStderr, onReporterEvent, } = options;
6133
+ const testSuiteDir = outputDir || getDefaultTestSuiteDir(testSuiteId, testSuiteName);
6134
+ let currentRunId = runId;
6135
+ try {
6136
+ // Create run record if not provided (needed for run-specific report directories)
6137
+ if (!currentRunId) {
6138
+ try {
6139
+ const createResponse = await fetch$1(`${apiUrl}/test-suites/${testSuiteId}/runs/`, {
6140
+ method: 'POST',
6141
+ headers: {
6142
+ 'Authorization': `Token ${apiToken}`,
6143
+ 'Content-Type': 'application/json',
6144
+ },
6145
+ });
6146
+ if (createResponse.ok) {
6147
+ const runData = await createResponse.json();
6148
+ currentRunId = runData.id;
6149
+ console.log(`✅ Created test suite run: ${currentRunId}`);
6150
+ }
6151
+ else {
6152
+ console.warn('⚠️ Failed to create run record, continuing without tracking');
6153
+ }
6154
+ }
6155
+ catch (error) {
6156
+ console.warn('⚠️ Failed to create run record:', error);
6157
+ }
6158
+ }
6159
+ // Generate all files (with runId if available for run-specific report directories)
6160
+ console.log(`📝 Generating test suite files for test suite ${testSuiteId}...`);
6161
+ await this.generateTestSuiteFiles(testSuiteId, apiToken, apiUrl, testSuiteDir, testSuiteName, includeReporter, currentRunId);
6162
+ // Count total tests
6163
+ const testsTotal = countTotalTests(testSuiteDir);
6164
+ if (currentRunId && testsTotal > 0) {
6165
+ await updateRunStatus(currentRunId, testSuiteId, { tests_total: testsTotal }, apiToken, apiUrl);
6166
+ if (onStatusUpdate) {
6167
+ await onStatusUpdate({ tests_total: testsTotal });
6168
+ }
6169
+ }
6170
+ // Install dependencies
6171
+ console.log(`📦 Installing dependencies in ${testSuiteDir}...`);
6172
+ try {
6173
+ const { stdout: installStdout, stderr: installStderr } = await execAsync('npm install', { cwd: testSuiteDir, timeout: 300000 } // 5 minute timeout for install
6174
+ );
6175
+ console.log('✅ Dependencies installed successfully');
6176
+ if (installStdout)
6177
+ console.log(installStdout);
6178
+ if (installStderr)
6179
+ console.warn(installStderr);
6180
+ }
6181
+ catch (installError) {
6182
+ console.error('❌ Failed to install dependencies:', installError);
6183
+ const errorMsg = `Failed to install dependencies: ${installError.message}`;
6184
+ if (currentRunId) {
6185
+ await updateRunStatus(currentRunId, testSuiteId, {
6186
+ status: 'error',
6187
+ exit_code: installError.code || 1,
6188
+ error_message: errorMsg,
6189
+ stderr: installError.stderr || installError.message || '',
6190
+ }, apiToken, apiUrl);
6191
+ if (onStatusUpdate) {
6192
+ await onStatusUpdate({
6193
+ status: 'error',
6194
+ exit_code: installError.code || 1,
6195
+ error_message: errorMsg,
6196
+ stderr: installError.stderr || installError.message || '',
6197
+ });
6198
+ }
6199
+ }
6200
+ return {
6201
+ success: false,
6202
+ exitCode: installError.code || 1,
6203
+ stdout: installError.stdout || '',
6204
+ stderr: installError.stderr || installError.message || '',
6205
+ error: errorMsg,
6206
+ runId: currentRunId,
6207
+ };
6208
+ }
6209
+ // Run Playwright tests with streaming output
6210
+ console.log(`🚀 Running Playwright tests in ${testSuiteDir}...`);
6211
+ return new Promise(async (resolve) => {
6212
+ var _a, _b;
6213
+ let stdout = '';
6214
+ let stderr = '';
6215
+ let hasResolved = false;
6216
+ let lastStatsUpdate = 0;
6217
+ let stdoutLineBuffer = '';
6218
+ const PROBO_REPORTER_PREFIX = '__PROBO_REPORTER_EVENT__';
6219
+ const reporterStats = { passed: 0, failed: 0, skipped: 0 };
6220
+ let lastReporterStatsUpdate = 0;
6221
+ const REPORTER_STATS_UPDATE_INTERVAL = 1500; // throttle API updates from reporter events
6222
+ const STATS_UPDATE_INTERVAL = 10000; // Update stats every 10 seconds
6223
+ const STEPS_UPDATE_INTERVAL = 2000; // Update steps every 2 seconds
6224
+ let lastStepsUpdate = 0;
6225
+ const collectedSteps = [];
6226
+ // Use spawn for streaming output
6227
+ const testProcess = spawn('npx', ['playwright', 'test'], {
6228
+ cwd: testSuiteDir,
6229
+ shell: true,
6230
+ stdio: ['ignore', 'pipe', 'pipe'],
6231
+ });
6232
+ // Stream stdout line by line
6233
+ (_a = testProcess.stdout) === null || _a === void 0 ? void 0 : _a.on('data', async (data) => {
6234
+ var _a, _b;
6235
+ const chunk = data.toString();
6236
+ stdoutLineBuffer += chunk;
6237
+ // Process complete lines so we can intercept reporter events without polluting logs.
6238
+ const parts = stdoutLineBuffer.split(/\n/);
6239
+ stdoutLineBuffer = (_a = parts.pop()) !== null && _a !== void 0 ? _a : '';
6240
+ let forwarded = '';
6241
+ for (const rawLine of parts) {
6242
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
6243
+ if (line.startsWith(PROBO_REPORTER_PREFIX)) {
6244
+ const jsonPart = line.slice(PROBO_REPORTER_PREFIX.length);
6245
+ try {
6246
+ const reporterEvent = JSON.parse(jsonPart);
6247
+ // Call reporter event callback if provided
6248
+ if (onReporterEvent) {
6249
+ await onReporterEvent(reporterEvent);
6250
+ }
6251
+ // Collect steps from reporter events
6252
+ if ((reporterEvent === null || reporterEvent === void 0 ? void 0 : reporterEvent.eventType) === 'stepBegin') {
6253
+ const step = reporterEvent === null || reporterEvent === void 0 ? void 0 : reporterEvent.step;
6254
+ const id = typeof (step === null || step === void 0 ? void 0 : step.id) === 'number' ? step.id : null;
6255
+ const title = typeof (step === null || step === void 0 ? void 0 : step.title) === 'string' ? step.title : null;
6256
+ const depth = typeof (step === null || step === void 0 ? void 0 : step.depth) === 'number' ? step.depth : 0;
6257
+ if (id && title) {
6258
+ // Check if step already exists (update) or add new
6259
+ const existingIndex = collectedSteps.findIndex(s => s.id === id);
6260
+ if (existingIndex === -1) {
6261
+ collectedSteps.push({ id, title, depth, status: 'running' });
6262
+ }
6263
+ }
6264
+ }
6265
+ if ((reporterEvent === null || reporterEvent === void 0 ? void 0 : reporterEvent.eventType) === 'stepEnd') {
6266
+ const step = reporterEvent === null || reporterEvent === void 0 ? void 0 : reporterEvent.step;
6267
+ const id = typeof (step === null || step === void 0 ? void 0 : step.id) === 'number' ? step.id : null;
6268
+ if (id) {
6269
+ const error = typeof (step === null || step === void 0 ? void 0 : step.error) === 'string' ? step.error : null;
6270
+ const existingIndex = collectedSteps.findIndex(s => s.id === id);
6271
+ if (existingIndex !== -1) {
6272
+ // Update existing step with final status
6273
+ collectedSteps[existingIndex] = {
6274
+ ...collectedSteps[existingIndex],
6275
+ status: error ? 'failed' : 'passed',
6276
+ error: error || null,
6277
+ };
6278
+ }
6279
+ // If step doesn't exist, ignore (stepBegin should have been called first)
6280
+ }
6281
+ }
6282
+ // Save steps periodically
6283
+ if (currentRunId) {
6284
+ const now = Date.now();
6285
+ if (now - lastStepsUpdate > STEPS_UPDATE_INTERVAL) {
6286
+ lastStepsUpdate = now;
6287
+ await updateRunStatus(currentRunId, testSuiteId, { steps: [...collectedSteps] }, apiToken, apiUrl);
6288
+ }
6289
+ }
6290
+ // Keep a more reliable running tally than parsing stdout.
6291
+ if (currentRunId && (reporterEvent === null || reporterEvent === void 0 ? void 0 : reporterEvent.eventType) === 'testEnd' && ((_b = reporterEvent === null || reporterEvent === void 0 ? void 0 : reporterEvent.result) === null || _b === void 0 ? void 0 : _b.status)) {
6292
+ const status = reporterEvent.result.status;
6293
+ if (status === 'passed')
6294
+ reporterStats.passed += 1;
6295
+ else if (status === 'failed' || status === 'timedout' || status === 'interrupted')
6296
+ reporterStats.failed += 1;
6297
+ else if (status === 'skipped')
6298
+ reporterStats.skipped += 1;
6299
+ const now = Date.now();
6300
+ if (now - lastReporterStatsUpdate > REPORTER_STATS_UPDATE_INTERVAL) {
6301
+ lastReporterStatsUpdate = now;
6302
+ await updateRunStatus(currentRunId, testSuiteId, {
6303
+ tests_passed: reporterStats.passed,
6304
+ tests_failed: reporterStats.failed,
6305
+ tests_skipped: reporterStats.skipped,
6306
+ }, apiToken, apiUrl);
6307
+ if (onStatusUpdate) {
6308
+ await onStatusUpdate({
6309
+ tests_passed: reporterStats.passed,
6310
+ tests_failed: reporterStats.failed,
6311
+ tests_skipped: reporterStats.skipped,
6312
+ });
6313
+ }
6314
+ }
6315
+ }
6316
+ }
6317
+ catch (_c) {
6318
+ // If parsing fails, ignore and don't forward to logs.
6319
+ }
6320
+ continue;
6321
+ }
6322
+ forwarded += rawLine + '\n';
6323
+ }
6324
+ if (forwarded) {
6325
+ stdout += forwarded;
6326
+ // Call stdout callback if provided
6327
+ if (onStdout) {
6328
+ await onStdout(forwarded);
6329
+ }
6330
+ }
6331
+ // Parse and update statistics periodically
6332
+ if (currentRunId) {
6333
+ const now = Date.now();
6334
+ // Only use stdout parsing as a fallback (in case reporter isn't loaded).
6335
+ if (now - lastStatsUpdate > STATS_UPDATE_INTERVAL && now - lastReporterStatsUpdate > REPORTER_STATS_UPDATE_INTERVAL) {
6336
+ lastStatsUpdate = now;
6337
+ const stats = parsePlaywrightStatistics(stdout);
6338
+ await updateRunStatus(currentRunId, testSuiteId, {
6339
+ tests_passed: stats.passed,
6340
+ tests_failed: stats.failed,
6341
+ tests_skipped: stats.skipped,
6342
+ }, apiToken, apiUrl);
6343
+ if (onStatusUpdate) {
6344
+ await onStatusUpdate({
6345
+ tests_passed: stats.passed,
6346
+ tests_failed: stats.failed,
6347
+ tests_skipped: stats.skipped,
6348
+ });
6349
+ }
6350
+ }
6351
+ }
6352
+ });
6353
+ // Stream stderr line by line
6354
+ (_b = testProcess.stderr) === null || _b === void 0 ? void 0 : _b.on('data', async (data) => {
6355
+ const chunk = data.toString();
6356
+ stderr += chunk;
6357
+ // Call stderr callback if provided
6358
+ if (onStderr) {
6359
+ await onStderr(chunk);
6360
+ }
6361
+ });
6362
+ // Handle process completion
6363
+ testProcess.on('close', async (code) => {
6364
+ var _a;
6365
+ if (hasResolved)
6366
+ return;
6367
+ hasResolved = true;
6368
+ // Flush any remaining buffered stdout (may be a partial last line)
6369
+ if (stdoutLineBuffer) {
6370
+ const remaining = stdoutLineBuffer;
6371
+ stdoutLineBuffer = '';
6372
+ if (remaining.startsWith(PROBO_REPORTER_PREFIX)) {
6373
+ try {
6374
+ const reporterEvent = JSON.parse(remaining.slice(PROBO_REPORTER_PREFIX.length));
6375
+ if (onReporterEvent) {
6376
+ await onReporterEvent(reporterEvent);
6377
+ }
6378
+ if (currentRunId && (reporterEvent === null || reporterEvent === void 0 ? void 0 : reporterEvent.eventType) === 'testEnd' && ((_a = reporterEvent === null || reporterEvent === void 0 ? void 0 : reporterEvent.result) === null || _a === void 0 ? void 0 : _a.status)) {
6379
+ const status = reporterEvent.result.status;
6380
+ if (status === 'passed')
6381
+ reporterStats.passed += 1;
6382
+ else if (status === 'failed' || status === 'timedout' || status === 'interrupted')
6383
+ reporterStats.failed += 1;
6384
+ else if (status === 'skipped')
6385
+ reporterStats.skipped += 1;
6386
+ }
6387
+ }
6388
+ catch (_b) {
6389
+ // ignore
6390
+ }
6391
+ }
6392
+ else {
6393
+ stdout += remaining;
6394
+ if (onStdout) {
6395
+ await onStdout(remaining);
6396
+ }
6397
+ }
6398
+ }
6399
+ const exitCode = code !== null && code !== void 0 ? code : 1;
6400
+ const success = exitCode === 0;
6401
+ const isTestFailure = exitCode === 1 && stdout.length > 0;
6402
+ console.log(success ? '✅ Tests completed successfully' : (isTestFailure ? '⚠️ Tests completed with failures' : '❌ Test execution failed'));
6403
+ // Parse final statistics from metadata/stdout as fallback
6404
+ let parsedStats = null;
6405
+ if (fs.existsSync(path.join(testSuiteDir, 'playwright-report'))) {
6406
+ parsedStats = parseStatisticsFromMetadata(testSuiteDir);
6407
+ }
6408
+ if (!parsedStats) {
6409
+ parsedStats = parsePlaywrightStatistics(stdout);
6410
+ }
6411
+ // Use reporterStats as source of truth (tracked from real-time events)
6412
+ // Fall back to parsed stats only if reporterStats is empty
6413
+ const totalFromReporter = reporterStats.passed + reporterStats.failed + reporterStats.skipped;
6414
+ const finalStats = totalFromReporter > 0 ? reporterStats : (parsedStats || { passed: 0, failed: 0, skipped: 0 });
6415
+ // Update run status with final steps
6416
+ if (currentRunId) {
6417
+ const finalStatus = success ? 'success' : 'error';
6418
+ await updateRunStatus(currentRunId, testSuiteId, {
6419
+ status: finalStatus,
6420
+ exit_code: exitCode,
6421
+ stdout: stdout,
6422
+ stderr: stderr,
6423
+ steps: [...collectedSteps], // Save final steps state
6424
+ error_message: success || isTestFailure ? undefined : `Test execution failed with exit code ${exitCode}`,
6425
+ tests_passed: finalStats.passed,
6426
+ tests_failed: finalStats.failed,
6427
+ tests_skipped: finalStats.skipped,
6428
+ }, apiToken, apiUrl);
6429
+ if (onStatusUpdate) {
6430
+ await onStatusUpdate({
6431
+ status: finalStatus,
6432
+ exit_code: exitCode,
6433
+ stdout: stdout,
6434
+ stderr: stderr,
6435
+ tests_passed: finalStats.passed,
6436
+ tests_failed: finalStats.failed,
6437
+ tests_skipped: finalStats.skipped,
6438
+ });
6439
+ }
6440
+ // Artifact uploads disabled - reports are now accessed locally via trace viewer
6441
+ // Upload artifacts functionality preserved but disabled
6442
+ // await this.uploadArtifacts(currentRunId, testSuiteId, testSuiteDir, apiToken, apiUrl);
6443
+ }
6444
+ resolve({
6445
+ success: success,
6446
+ exitCode: exitCode,
6447
+ stdout: stdout,
6448
+ stderr: stderr,
6449
+ // Only set error if it's not a success and not a test failure (i.e., real execution error)
6450
+ error: success || isTestFailure ? undefined : `Test execution failed with exit code ${exitCode}`,
6451
+ runId: currentRunId,
6452
+ });
6453
+ });
6454
+ // Handle process errors
6455
+ testProcess.on('error', async (error) => {
6456
+ if (hasResolved)
6457
+ return;
6458
+ hasResolved = true;
6459
+ const errorMessage = error.message || String(error);
6460
+ stderr += errorMessage;
6461
+ console.error('❌ Test execution failed:', error);
6462
+ // Update run status
6463
+ if (currentRunId) {
6464
+ await updateRunStatus(currentRunId, testSuiteId, {
6465
+ status: 'error',
6466
+ exit_code: 1,
6467
+ error_message: `Test execution failed: ${errorMessage}`,
6468
+ stderr: stderr,
6469
+ }, apiToken, apiUrl);
6470
+ if (onStatusUpdate) {
6471
+ await onStatusUpdate({
6472
+ status: 'error',
6473
+ exit_code: 1,
6474
+ error_message: `Test execution failed: ${errorMessage}`,
6475
+ stderr: stderr,
6476
+ });
6477
+ }
6478
+ }
6479
+ resolve({
6480
+ success: false,
6481
+ exitCode: 1,
6482
+ stdout: stdout,
6483
+ stderr: stderr,
6484
+ error: `Test execution failed: ${errorMessage}`,
6485
+ runId: currentRunId,
6486
+ });
6487
+ });
6488
+ // Set timeout (1 hour)
6489
+ setTimeout(async () => {
6490
+ if (!hasResolved) {
6491
+ hasResolved = true;
6492
+ testProcess.kill();
6493
+ const errorMessage = 'Test execution timed out after 1 hour';
6494
+ stderr += errorMessage;
6495
+ // Update run status
6496
+ if (currentRunId) {
6497
+ await updateRunStatus(currentRunId, testSuiteId, {
6498
+ status: 'error',
6499
+ exit_code: 1,
6500
+ error_message: errorMessage,
6501
+ stderr: stderr,
6502
+ }, apiToken, apiUrl);
6503
+ if (onStatusUpdate) {
6504
+ await onStatusUpdate({
6505
+ status: 'error',
6506
+ exit_code: 1,
6507
+ error_message: errorMessage,
6508
+ stderr: stderr,
6509
+ });
6510
+ }
6511
+ }
6512
+ resolve({
6513
+ success: false,
6514
+ exitCode: 1,
6515
+ stdout: stdout,
6516
+ stderr: stderr,
6517
+ error: errorMessage,
6518
+ runId: currentRunId,
6519
+ });
6520
+ }
6521
+ }, 3600000); // 1 hour timeout
6522
+ });
6523
+ }
6524
+ catch (error) {
6525
+ console.error('❌ Error running test suite:', error);
6526
+ const errorMsg = error.message || String(error);
6527
+ if (currentRunId) {
6528
+ await updateRunStatus(currentRunId, testSuiteId, {
6529
+ status: 'error',
6530
+ exit_code: 1,
6531
+ error_message: errorMsg,
6532
+ }, apiToken, apiUrl).catch(() => { }); // Ignore errors updating status
6533
+ if (onStatusUpdate) {
6534
+ try {
6535
+ const result = onStatusUpdate({
6536
+ status: 'error',
6537
+ exit_code: 1,
6538
+ error_message: errorMsg,
6539
+ });
6540
+ if (result && typeof result.then === 'function') {
6541
+ await result.catch(() => { });
6542
+ }
6543
+ }
6544
+ catch (_a) {
6545
+ // Ignore errors in status update callback
6546
+ }
6547
+ }
6548
+ }
6549
+ return {
6550
+ success: false,
6551
+ exitCode: 1,
6552
+ stdout: '',
6553
+ stderr: errorMsg,
6554
+ error: errorMsg,
6555
+ runId: currentRunId,
6556
+ };
6557
+ }
6558
+ }
6559
+ /**
6560
+ * Upload artifacts for a test suite run
6561
+ *
6562
+ * NOTE: This method is preserved for potential future use but is currently disabled.
6563
+ * Artifacts are now accessed locally via the trace viewer instead of being uploaded to the backend.
6564
+ */
6565
+ static async uploadArtifacts(runId, testSuiteId, testSuiteDir, apiToken, apiUrl) {
6566
+ // Artifact uploads disabled - reports are now accessed locally via trace viewer
6567
+ // Method signature preserved for potential future re-enablement
6568
+ return;
6569
+ }
6570
+ }
6571
+
4753
6572
  class Probo {
4754
6573
  constructor({ scenarioName, token = '', apiUrl = '', enableConsoleLogs = false, enableSmartSelectors = false, logToConsole = true, logToFile = false, debugLevel = ProboLogLevel.INFO, aiModel = AIModel.AZURE_GPT4_MINI, timeoutConfig = {} }) {
4755
6574
  this.params = {};
@@ -5113,5 +6932,5 @@ class Probo {
5113
6932
  }
5114
6933
  }
5115
6934
 
5116
- export { AIModel, Highlighter, NavTracker, OTP, PlaywrightAction, Probo, ProboLogLevel, ProboPlaywright, findClosestVisibleElement };
6935
+ export { AIModel, Highlighter, NavTracker, OTP, PlaywrightAction, Probo, ProboCodeGenerator, ProboLogLevel, ProboPlaywright, TestSuiteRunner, createUrlSlug, extractRequiredEnvVars, findClosestVisibleElement, generateCode, getRequiredEnvVars };
5117
6936
  //# sourceMappingURL=index.js.map