#
tokens: 36319/50000 4/40 files (page 2/4)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 2 of 4. Use http://codebase.md/ilikepizza2/qa-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .env.example
├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── main.py
├── mcp_server.py
├── README.md
├── requirements.txt
├── src
│   ├── __init__.py
│   ├── agents
│   │   ├── __init__.py
│   │   ├── auth_agent.py
│   │   ├── crawler_agent.py
│   │   ├── js_utils
│   │   │   └── xpathgenerator.js
│   │   └── recorder_agent.py
│   ├── browser
│   │   ├── __init__.py
│   │   ├── browser_controller.py
│   │   └── panel
│   │       └── panel.py
│   ├── core
│   │   ├── __init__.py
│   │   └── task_manager.py
│   ├── dom
│   │   ├── buildDomTree.js
│   │   ├── history
│   │   │   ├── service.py
│   │   │   └── view.py
│   │   ├── service.py
│   │   └── views.py
│   ├── execution
│   │   ├── __init__.py
│   │   └── executor.py
│   ├── llm
│   │   ├── __init__.py
│   │   ├── clients
│   │   │   ├── azure_openai_client.py
│   │   │   ├── gemini_client.py
│   │   │   └── openai_client.py
│   │   └── llm_client.py
│   ├── security
│   │   ├── __init__.py
│   │   ├── nuclei_scanner.py
│   │   ├── semgrep_scanner.py
│   │   ├── utils.py
│   │   └── zap_scanner.py
│   └── utils
│       ├── __init__.py
│       ├── image_utils.py
│       └── utils.py
└── test_schema.md
```

# Files

--------------------------------------------------------------------------------
/src/dom/service.py:
--------------------------------------------------------------------------------

```python
  1 | # /src/dom/service.py
  2 | import gc
  3 | import json
  4 | import logging
  5 | from dataclasses import dataclass
  6 | from importlib import resources # Use importlib.resources
  7 | from typing import TYPE_CHECKING, Optional, Tuple, Dict, List
  8 | import re
  9 | 
 10 | # Use relative imports if within the same package structure
 11 | from .views import (
 12 |     DOMBaseNode,
 13 |     DOMElementNode,
 14 |     DOMState,
 15 |     DOMTextNode,
 16 |     SelectorMap,
 17 |     ViewportInfo, # Added ViewportInfo here
 18 |     CoordinateSet # Added CoordinateSet
 19 | )
 20 | # Removed utils import assuming time_execution_async is defined elsewhere or removed for brevity
 21 | # from ..utils import time_execution_async # Example relative import if utils is one level up
 22 | 
 23 | if TYPE_CHECKING:
 24 |     from patchright.sync_api import Page # Use sync_api for this repo
 25 | 
 26 | logger = logging.getLogger(__name__)
 27 | 
 28 | # Decorator placeholder if not using utils.time_execution_async
 29 | def time_execution_async(label):
 30 |     def decorator(func):
 31 |         # In a sync context, this decorator needs adjustment or removal
 32 |         # For simplicity here, we'll just make it pass through in the sync version
 33 |         def wrapper(*args, **kwargs):
 34 |             # logger.debug(f"Executing {label}...") # Basic logging
 35 |             result = func(*args, **kwargs)
 36 |             # logger.debug(f"Finished {label}.") # Basic logging
 37 |             return result
 38 |         return wrapper
 39 |     return decorator
 40 | 
 41 | 
 42 | class DomService:
 43 |     def __init__(self, page: 'Page'):
 44 |         self.page = page
 45 |         self.xpath_cache = {} # Consider if this cache is still needed/used effectively
 46 | 
 47 |         # Correctly load JS using importlib.resources relative to this file
 48 |         try:
 49 |             # Assuming buildDomTree.js is in the same directory 'dom'
 50 |             with resources.path(__package__, 'buildDomTree.js') as js_path:
 51 |                  self.js_code = js_path.read_text(encoding='utf-8')
 52 |             logger.debug("buildDomTree.js loaded successfully.")
 53 |         except FileNotFoundError:
 54 |              logger.error("buildDomTree.js not found in the 'dom' package directory!")
 55 |              raise
 56 |         except Exception as e:
 57 |              logger.error(f"Error loading buildDomTree.js: {e}", exc_info=True)
 58 |              raise
 59 | 
 60 |     # region - Clickable elements
 61 |     @time_execution_async('--get_clickable_elements')
 62 |     def get_clickable_elements(
 63 |         self,
 64 |         highlight_elements: bool = True,
 65 |         focus_element: int = -1,
 66 |         viewport_expansion: int = 0,
 67 |     ) -> DOMState:
 68 |         """Gets interactive elements and DOM structure. Sync version."""
 69 |         logger.debug(f"Calling _build_dom_tree with highlight={highlight_elements}, focus={focus_element}, expansion={viewport_expansion}")
 70 |         # In sync context, _build_dom_tree should be sync
 71 |         element_tree, selector_map = self._build_dom_tree(highlight_elements, focus_element, viewport_expansion)
 72 |         return DOMState(element_tree=element_tree, selector_map=selector_map)
 73 | 
 74 |     # Removed get_cross_origin_iframes for brevity, can be added back if needed
 75 | 
 76 |     # @time_execution_async('--build_dom_tree') # Adjust decorator if needed for sync
 77 |     def _build_dom_tree(
 78 |         self,
 79 |         highlight_elements: bool,
 80 |         focus_element: int,
 81 |         viewport_expansion: int,
 82 |     ) -> Tuple[DOMElementNode, SelectorMap]:
 83 |         """Builds the DOM tree by executing JS in the browser. Sync version."""
 84 |         logger.debug("Executing _build_dom_tree...")
 85 |         if self.page.evaluate('1+1') != 2:
 86 |             raise ValueError('The page cannot evaluate javascript code properly')
 87 | 
 88 |         if self.page.url == 'about:blank' or self.page.url == '':
 89 |             logger.info("Page URL is blank, returning empty DOM structure.")
 90 |             # short-circuit if the page is a new empty tab for speed
 91 |             return (
 92 |                 DOMElementNode(
 93 |                     tag_name='body',
 94 |                     xpath='',
 95 |                     attributes={},
 96 |                     children=[],
 97 |                     is_visible=False,
 98 |                     parent=None,
 99 |                 ),
100 |                 {},
101 |             )
102 | 
103 |         debug_mode = logger.getEffectiveLevel() <= logging.DEBUG
104 |         args = {
105 |             'doHighlightElements': highlight_elements,
106 |             'focusHighlightIndex': focus_element,
107 |             'viewportExpansion': viewport_expansion,
108 |             'debugMode': debug_mode,
109 |         }
110 |         logger.debug(f"Evaluating buildDomTree.js with args: {args}")
111 | 
112 |         try:
113 |             # Use evaluate() directly in sync context
114 |             eval_page: dict = self.page.evaluate(f"({self.js_code})", args)
115 | 
116 |         except Exception as e:
117 |             logger.error(f"Error evaluating buildDomTree.js: {type(e).__name__}: {e}", exc_info=False) # Less verbose logging
118 |             logger.debug(f"JS Code Snippet (first 500 chars):\n{self.js_code[:500]}...") # Log JS snippet on error
119 |             # Try to get page state for context
120 |             try:
121 |                  page_url = self.page.url
122 |                  page_title = self.page.title()
123 |                  logger.error(f"Error occurred on page: URL='{page_url}', Title='{page_title}'")
124 |             except Exception as page_state_e:
125 |                  logger.error(f"Could not get page state after JS error: {page_state_e}")
126 |             raise RuntimeError(f"Failed to evaluate DOM building script: {e}") from e # Re-raise a standard error
127 | 
128 | 
129 |         # Only log performance metrics in debug mode
130 |         if debug_mode and 'perfMetrics' in eval_page:
131 |              logger.debug(
132 |                  'DOM Tree Building Performance Metrics for: %s\n%s',
133 |                  self.page.url,
134 |                  json.dumps(eval_page['perfMetrics'], indent=2),
135 |              )
136 | 
137 |         if 'map' not in eval_page or 'rootId' not in eval_page:
138 |             logger.error(f"Invalid structure returned from buildDomTree.js: Missing 'map' or 'rootId'. Response keys: {eval_page.keys()}")
139 |             # Log more details if possible
140 |             logger.error(f"JS Eval Response Snippet: {str(eval_page)[:1000]}...")
141 |             # Return empty structure to prevent downstream errors
142 |             return (DOMElementNode(tag_name='body', xpath='', attributes={}, children=[], is_visible=False, parent=None), {})
143 |             # raise ValueError("Invalid structure returned from DOM building script.")
144 | 
145 |         # Use sync _construct_dom_tree
146 |         return self._construct_dom_tree(eval_page)
147 | 
148 |     # @time_execution_async('--construct_dom_tree') # Adjust decorator if needed for sync
149 |     def _construct_dom_tree(
150 |         self,
151 |         eval_page: dict,
152 |     ) -> Tuple[DOMElementNode, SelectorMap]:
153 |         """Constructs the Python DOM tree from the JS map. Sync version."""
154 |         logger.debug("Constructing Python DOM tree from JS map...")
155 |         js_node_map = eval_page['map']
156 |         js_root_id = eval_page.get('rootId') # Use .get for safety
157 | 
158 |         if js_root_id is None:
159 |              logger.error("JS evaluation result missing 'rootId'. Cannot build tree.")
160 |              # Return empty structure
161 |              return (DOMElementNode(tag_name='body', xpath='', attributes={}, children=[], is_visible=False, parent=None), {})
162 | 
163 | 
164 |         selector_map: SelectorMap = {}
165 |         node_map: Dict[str, DOMBaseNode] = {} # Use string keys consistently
166 | 
167 |         # Iterate through the JS map provided by the browser script
168 |         for id_str, node_data in js_node_map.items():
169 |             if not isinstance(node_data, dict):
170 |                  logger.warning(f"Skipping invalid node data (not a dict) for ID: {id_str}")
171 |                  continue
172 | 
173 |             node, children_ids_str = self._parse_node(node_data)
174 |             if node is None:
175 |                 continue # Skip nodes that couldn't be parsed
176 | 
177 |             node_map[id_str] = node # Store with string ID
178 | 
179 |             # If the node is an element node with a highlight index, add it to the selector map
180 |             if isinstance(node, DOMElementNode) and node.highlight_index is not None:
181 |                 selector_map[node.highlight_index] = node
182 | 
183 |             # Link children to this node if it's an element node
184 |             if isinstance(node, DOMElementNode):
185 |                 for child_id_str in children_ids_str:
186 |                     child_node = node_map.get(child_id_str) # Use .get() for safety
187 |                     if child_node:
188 |                         # Set the parent reference on the child node
189 |                         child_node.parent = node
190 |                         # Add the child node to the current node's children list
191 |                         node.children.append(child_node)
192 |                     else:
193 |                         # This can happen if a child node was invalid or filtered out
194 |                         logger.debug(f"Child node with ID '{child_id_str}' not found in node_map while processing parent '{id_str}'.")
195 | 
196 | 
197 |         # Retrieve the root node using the root ID from the evaluation result
198 |         root_node = node_map.get(str(js_root_id))
199 | 
200 |         # Clean up large intermediate structures
201 |         del node_map
202 |         del js_node_map
203 |         gc.collect()
204 | 
205 |         # Validate the root node
206 |         if root_node is None or not isinstance(root_node, DOMElementNode):
207 |             logger.error(f"Failed to find valid root DOMElementNode with ID '{js_root_id}'.")
208 |             # Return a default empty body node to avoid crashes
209 |             return (DOMElementNode(tag_name='body', xpath='', attributes={}, children=[], is_visible=False, parent=None), selector_map)
210 | 
211 |         logger.debug("Finished constructing Python DOM tree.")
212 |         return root_node, selector_map
213 | 
214 | 
215 |     def _parse_node(
216 |         self,
217 |         node_data: dict,
218 |     ) -> Tuple[Optional[DOMBaseNode], List[str]]: # Return string IDs
219 |         """Parses a single node dictionary from JS into a Python DOM object. Sync version."""
220 |         if not node_data:
221 |             return None, []
222 | 
223 |         node_type = node_data.get('type') # Check if it's explicitly a text node
224 | 
225 |         if node_type == 'TEXT_NODE':
226 |              # Handle Text Nodes
227 |              text = node_data.get('text', '')
228 |              if not text: # Skip empty text nodes early
229 |                   return None, []
230 |              text_node = DOMTextNode(
231 |                  text=text,
232 |                  is_visible=node_data.get('isVisible', False), # Use .get for safety
233 |                  parent=None, # Parent set later during construction
234 |              )
235 |              return text_node, []
236 |         elif 'tagName' in node_data:
237 |              # Handle Element Nodes
238 |              tag_name = node_data['tagName']
239 | 
240 |              # Process coordinates if they exist (using Pydantic models from view)
241 |              page_coords_data = node_data.get('pageCoordinates')
242 |              viewport_coords_data = node_data.get('viewportCoordinates')
243 |              viewport_info_data = node_data.get('viewportInfo')
244 | 
245 |              page_coordinates = CoordinateSet(**page_coords_data) if page_coords_data else None
246 |              viewport_coordinates = CoordinateSet(**viewport_coords_data) if viewport_coords_data else None
247 |              viewport_info = ViewportInfo(**viewport_info_data) if viewport_info_data else None
248 | 
249 |              element_node = DOMElementNode(
250 |                  tag_name=tag_name.lower(), # Ensure lowercase
251 |                  xpath=node_data.get('xpath', ''),
252 |                  attributes=node_data.get('attributes', {}),
253 |                  children=[], # Children added later
254 |                  is_visible=node_data.get('isVisible', False),
255 |                  is_interactive=node_data.get('isInteractive', False),
256 |                  is_top_element=node_data.get('isTopElement', False),
257 |                  is_in_viewport=node_data.get('isInViewport', False),
258 |                  highlight_index=node_data.get('highlightIndex'), # Can be None
259 |                  shadow_root=node_data.get('shadowRoot', False),
260 |                  parent=None, # Parent set later
261 |                  # Add coordinate fields
262 |                  page_coordinates=page_coordinates,
263 |                  viewport_coordinates=viewport_coordinates,
264 |                  viewport_info=viewport_info,
265 |                  # Enhanced CSS selector added later if needed
266 |                  css_selector=None,
267 |              )
268 |              # Children IDs are strings from the JS map
269 |              children_ids_str = node_data.get('children', [])
270 |              # Basic validation
271 |              if not isinstance(children_ids_str, list):
272 |                  logger.warning(f"Invalid children format for node {node_data.get('xpath')}, expected list, got {type(children_ids_str)}. Treating as empty.")
273 |                  children_ids_str = []
274 | 
275 |              return element_node, [str(cid) for cid in children_ids_str] # Ensure IDs are strings
276 |         else:
277 |              # Skip nodes that are neither TEXT_NODE nor have a tagName (e.g., comments processed out by JS)
278 |              logger.debug(f"Skipping node data without 'type' or 'tagName': {str(node_data)[:100]}...")
279 |              return None, []
280 | 
281 |     # Add the helper to generate enhanced CSS selectors (adapted from BrowserContext)
282 |     # This could also live in a dedicated selector utility class/module
283 |     @staticmethod
284 |     def _enhanced_css_selector_for_element(element: DOMElementNode) -> str:
285 |         """
286 |         Generates a more robust CSS selector, prioritizing stable attributes.
287 |         RECORDER FOCUS: Prioritize ID, data-testid, name, stable classes. Fallback carefully.
288 |         """
289 |         if not isinstance(element, DOMElementNode):
290 |             return ''
291 | 
292 |         # Escape CSS identifiers (simple version, consider edge cases)
293 |         def escape_css(value):
294 |             if not value: return ''
295 |             # Basic escape for characters that are problematic in unquoted identifiers/strings
296 |             # See: https://developer.mozilla.org/en-US/docs/Web/CSS/string#escaping_characters
297 |             # This is NOT exhaustive but covers common cases.
298 |             return re.sub(r'([!"#$%&\'()*+,./:;<=>?@\[\\\]^`{|}~])', r'\\\1', value)
299 | 
300 | 
301 |         # --- Attribute Priority Order ---
302 |         # 1. ID (if reasonably unique-looking)
303 |         if 'id' in element.attributes and element.attributes['id']:
304 |             element_id = element.attributes['id'].strip()
305 |             if element_id and not element_id.isdigit() and ' ' not in element_id and ':' not in element_id:
306 |                  escaped_id = escape_css(element_id)
307 |                  selector = f"#{escaped_id}"
308 |                  # If ID seems generic, add tag name
309 |                  if len(element_id) < 6 and element.tag_name not in ['div', 'span']: # Don't add for generic containers unless ID is short
310 |                      return f"{element.tag_name}{selector}"
311 |                  return selector
312 | 
313 |         # 2. Stable Data Attributes
314 |         for test_attr in ['data-testid', 'data-test-id', 'data-cy', 'data-qa']:
315 |             if test_attr in element.attributes and element.attributes[test_attr]:
316 |                 val = element.attributes[test_attr].strip()
317 |                 if val:
318 |                      escaped_val = escape_css(val)
319 |                      selector = f"[{test_attr}='{escaped_val}']"
320 |                      # Add tag name if value seems generic
321 |                      if len(val) < 5:
322 |                          return f"{element.tag_name}{selector}"
323 |                      return selector
324 | 
325 |         # 3. Name Attribute
326 |         if 'name' in element.attributes and element.attributes['name']:
327 |              name_val = element.attributes['name'].strip()
328 |              if name_val:
329 |                   escaped_name = escape_css(name_val)
330 |                   selector = f"{element.tag_name}[name='{escaped_name}']"
331 |                   return selector
332 | 
333 |         # 4. Aria-label
334 |         if 'aria-label' in element.attributes and element.attributes['aria-label']:
335 |              aria_label = element.attributes['aria-label'].strip()
336 |              # Ensure label is reasonably specific (not just whitespace or very short)
337 |              if aria_label and len(aria_label) > 2 and len(aria_label) < 80:
338 |                   escaped_label = escape_css(aria_label)
339 |                   selector = f"{element.tag_name}[aria-label='{escaped_label}']"
340 |                   return selector
341 | 
342 |         # 5. Placeholder (for inputs)
343 |         if element.tag_name == 'input' and 'placeholder' in element.attributes and element.attributes['placeholder']:
344 |              placeholder = element.attributes['placeholder'].strip()
345 |              if placeholder:
346 |                   escaped_placeholder = escape_css(placeholder)
347 |                   selector = f"input[placeholder='{escaped_placeholder}']"
348 |                   return selector
349 | 
350 |         # --- Text Content Strategy (Use cautiously) ---
351 |         # Get DIRECT, visible text content of the element itself
352 |         direct_text = ""
353 |         if element.is_visible: # Only consider text if element is visible
354 |             texts = []
355 |             for child in element.children:
356 |                 if isinstance(child, DOMTextNode) and child.is_visible:
357 |                     texts.append(child.text.strip())
358 |             direct_text = ' '.join(filter(None, texts)).strip()
359 | 
360 |         # 6. Specific Text Content (if short, unique-looking, and element type is suitable)
361 |         suitable_text_tags = {'button', 'a', 'span', 'label', 'legend', 'h1', 'h2', 'h3', 'h4', 'p', 'li', 'td', 'th', 'dt', 'dd'}
362 |         if direct_text and element.tag_name in suitable_text_tags and 2 < len(direct_text) < 60: # Avoid overly long or short text
363 |              # Basic check for uniqueness (could be improved by checking siblings)
364 |              # Check if it looks like dynamic content (e.g., numbers only, dates) - skip if so
365 |              if not direct_text.isdigit() and not re.match(r'^\$?[\d,.]+$', direct_text): # Avoid pure numbers/prices
366 |                 # Use Playwright's text selector (escapes internally)
367 |                 # Note: This requires Playwright >= 1.15 or so for :text pseudo-class
368 |                 # Using :has-text is generally safer as it looks within descendants too,
369 |                 # but here we specifically want the *direct* text match.
370 |                 # Let's try combining tag and text for specificity.
371 |                 # Playwright handles quotes inside the text automatically.
372 |                 selector = f"{element.tag_name}:text-is('{direct_text}')"
373 |                 # Alternative: :text() - might be less strict about whitespace
374 |                 # selector = f"{element.tag_name}:text('{direct_text}')"
375 |                 # Let's try to validate this selector immediately if possible (costly)
376 |                 # For now, return it optimistically.
377 |                 return selector
378 | 
379 |         # --- Fallbacks (Structure and Class) ---
380 |         base_selector = element.tag_name
381 |         stable_classes_used = []
382 | 
383 |         # 7. Stable Class Names (Filter more strictly)
384 |         if 'class' in element.attributes and element.attributes['class']:
385 |             classes = element.attributes['class'].strip().split()
386 |             stable_classes = [
387 |                 c for c in classes
388 |                 if c and not c.isdigit() and
389 |                    not re.search(r'\d', c) and # No digits at all
390 |                    not re.match(r'.*(--|__|is-|has-|js-|active|selected|disabled|hidden).*', c, re.IGNORECASE) and # Avoid common states/modifiers/js
391 |                    not re.match(r'^[a-zA-Z]{1,2}$', c) and # Avoid 1-2 letter classes (often layout helpers)
392 |                    len(c) > 2 and len(c) < 30 # Reasonable length
393 |             ]
394 |             if stable_classes:
395 |                 stable_classes.sort()
396 |                 stable_classes_used = stable_classes # Store for nth-of-type check
397 |                 base_selector += '.' + '.'.join(escape_css(c) for c in stable_classes)
398 | 
399 |         # --- Ancestor Context (Find nearest stable ancestor) ---
400 |         # Try to find a parent with ID or data-testid to anchor the selector
401 |         stable_ancestor_selector = None
402 |         current = element.parent
403 |         depth = 0
404 |         max_depth = 4 # How far up to look for an anchor
405 |         while current and depth < max_depth:
406 |             ancestor_selector_part = None
407 |             if 'id' in current.attributes and current.attributes['id']:
408 |                  ancestor_id = current.attributes['id'].strip()
409 |                  if ancestor_id and not ancestor_id.isdigit() and ' ' not in ancestor_id:
410 |                       ancestor_selector_part = f"#{escape_css(ancestor_id)}"
411 |             elif not ancestor_selector_part: # Check testid only if ID not found
412 |                 for test_attr in ['data-testid', 'data-test-id']:
413 |                     if test_attr in current.attributes and current.attributes[test_attr]:
414 |                         val = current.attributes[test_attr].strip()
415 |                         if val:
416 |                             ancestor_selector_part = f"[{test_attr}='{escape_css(val)}']"
417 |                             break # Found one
418 |             # If we found a stable part for the ancestor, use it
419 |             if ancestor_selector_part:
420 |                 stable_ancestor_selector = ancestor_selector_part
421 |                 break # Stop searching up
422 |             current = current.parent
423 |             depth += 1
424 | 
425 |         # Combine ancestor and base selector if ancestor found
426 |         final_selector = f"{stable_ancestor_selector} >> {base_selector}" if stable_ancestor_selector else base_selector
427 | 
428 |         # 8. Add :nth-of-type ONLY if multiple siblings match the current selector AND no unique attribute/text was found
429 |         # This check becomes more complex with the ancestor path. We simplify here.
430 |         # Only add nth-of-type if we didn't find a unique ID/testid/name/text for the element itself.
431 |         needs_disambiguation = (stable_ancestor_selector is None) and \
432 |                                (base_selector == element.tag_name or base_selector.startswith(element.tag_name + '.')) # Only tag or tag+class
433 | 
434 |         if needs_disambiguation and element.parent:
435 |             try:
436 |                 # Find siblings matching the base selector part (tag + potentially classes)
437 |                 matching_siblings = []
438 |                 for sib in element.parent.children:
439 |                     if isinstance(sib, DOMElementNode) and sib.tag_name == element.tag_name:
440 |                         # Check classes if they were used in the base selector
441 |                         if stable_classes_used:
442 |                             if DomService._check_classes_match(sib, stable_classes_used):
443 |                                 matching_siblings.append(sib)
444 |                         else: # No classes used, just match tag
445 |                             matching_siblings.append(sib)
446 | 
447 |                 if len(matching_siblings) > 1:
448 |                      try:
449 |                           index = matching_siblings.index(element) + 1
450 |                           final_selector += f':nth-of-type({index})'
451 |                      except ValueError:
452 |                           logger.warning(f"Element not found in its own filtered sibling list for nth-of-type. Selector: {final_selector}")
453 |             except Exception as e:
454 |                 logger.warning(f"Error during nth-of-type calculation: {e}. Selector: {final_selector}")
455 | 
456 |         # 9. FINAL FALLBACK: Use original XPath if selector is still not specific
457 |         if final_selector == element.tag_name and element.xpath:
458 |              logger.warning(f"Selector for {element.tag_name} is just the tag. Falling back to XPath: {element.xpath}")
459 |              # Returning XPath directly might cause issues if executor expects CSS.
460 |              # Playwright can handle css=<xpath>, so let's return that.
461 |              return f"xpath={element.xpath}"
462 | 
463 |         return final_selector
464 |     
465 |     @staticmethod
466 |     def _check_classes_match(element: DOMElementNode, required_classes: List[str]) -> bool:
467 |         """Helper to check if an element has all the required classes."""
468 |         if 'class' not in element.attributes or not element.attributes['class']:
469 |             return False
470 |         element_classes = set(element.attributes['class'].strip().split())
471 |         return all(req_class in element_classes for req_class in required_classes)
```

--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------

```python
  1 | # main.py
  2 | import sys
  3 | import os
  4 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '.')))
  5 | import time
  6 | import json 
  7 | import argparse
  8 | 
  9 | from src.agents.recorder_agent import WebAgent
 10 | from src.agents.crawler_agent import CrawlerAgent
 11 | from src.llm.llm_client import LLMClient
 12 | from src.execution.executor import TestExecutor
 13 | from src.utils.utils import load_api_key, load_api_version, load_api_base_url, load_llm_model
 14 | from src.agents.auth_agent import record_selectors_and_save_auth_state
 15 | from src.security.utils import save_report
 16 | from src.security.semgrep_scanner import run_semgrep
 17 | 
 18 | import logging
 19 | import warnings
 20 | 
 21 | if __name__ == "__main__":
 22 |     # Configure logging (DEBUG for detailed logs, INFO for less)
 23 |     logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
 24 |     # logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
 25 |     # Suppress noisy logs from specific libraries if needed
 26 |     logging.getLogger("urllib3").setLevel(logging.WARNING)
 27 |     logging.getLogger("playwright").setLevel(logging.INFO) # Show Playwright info but not debug
 28 | 
 29 |     logger = logging.getLogger(__name__) # Logger for main script
 30 | 
 31 |     # --- Argument Parser ---
 32 |     parser = argparse.ArgumentParser(description="AI Web Testing Agent - Recorder & Executor")
 33 |     parser.add_argument(
 34 |         '--mode',
 35 |         choices=['record', 'execute','auth' ,'discover', 'security'],
 36 |         required=True,
 37 |         help="Mode to run the agent in: 'record' (interactive AI-assisted recording) or 'execute' (deterministic playback)."
 38 |     )
 39 |     parser.add_argument(
 40 |         '--file',
 41 |         type=str,
 42 |         help="Path to the JSON test file (required for 'execute' mode)."
 43 |     )
 44 |     parser.add_argument(
 45 |         '--headless',
 46 |         action='store_true', # Makes it a flag, default False
 47 |         help="Run executor in headless mode (only applies to 'execute'/discover mode)."
 48 |     )
 49 |     parser.add_argument(
 50 |         '--url', # <<< Added URL argument for discover mode
 51 |         type=str,
 52 |         help="Starting URL for website crawling/security (required for 'discover' and 'security' mode)."
 53 |     )
 54 |     parser.add_argument(
 55 |         '--max-pages', # <<< Added max pages argument for discover mode
 56 |         type=int,
 57 |         default=10,
 58 |         help="Maximum number of pages to crawl in 'discover' mode (default: 10)."
 59 |     )
 60 |     parser.add_argument(
 61 |         '--automated',
 62 |         action='store_true', # Use action='store_true' for boolean flags
 63 |         help="Run recorder in automated mode (AI makes decisions without user prompts). Only applies to 'record' mode." # Clarified help text
 64 |     )
 65 |     parser.add_argument(
 66 |         '--enable-healing',
 67 |         action='store_true',
 68 |         help="Enable self-healing during execution ('execute' mode only)."
 69 |     )
 70 |     parser.add_argument(
 71 |         '--healing-mode',
 72 |         choices=['soft', 'hard'],
 73 |         default='soft',
 74 |         help="Self-healing mode: 'soft' (fix selector) or 'hard' (re-record) ('execute' mode only)."
 75 |     )
 76 |     parser.add_argument("--code-path", help="Path to the codebase directory for Semgrep scan (optional).")
 77 |     parser.add_argument("--output-dir", default="results", help="Directory to save scan reports.")
 78 |     parser.add_argument('--provider', choices=['gemini', 'openai', 'azure'], default='gemini', help="LLM provider (default: gemini). Choose openai for any OpenAI compatible LLMs.")
 79 |     parser.add_argument("--semgrep-config", default="auto", help="Semgrep config/ruleset (e.g., 'p/ci', 'r/python'). Default is 'auto'.")
 80 |     parser.add_argument("--semgrep-timeout", type=int, default=600, help="Semgrep scan timeout in seconds.")
 81 |     
 82 |     args = parser.parse_args()
 83 | 
 84 |     # Validate arguments based on mode
 85 |     if args.mode == 'execute':
 86 |         if not args.file:
 87 |             parser.error("--file is required when --mode is 'execute'")
 88 |         if not args.enable_healing and args.healing_mode != 'soft':
 89 |              logger.warning("--healing-mode is ignored when --enable-healing is not set.")
 90 |     elif args.mode == 'record':
 91 |         if args.enable_healing:
 92 |              logger.warning("--enable-healing and --healing-mode are ignored in 'record' mode.")
 93 |     elif args.mode == 'discover':
 94 |         if not args.url:
 95 |              parser.error("--url is required when --mode is 'discover'")
 96 |         if args.enable_healing:
 97 |             logger.warning("--enable-healing and --healing-mode are ignored in 'discover' mode.")
 98 |     # --- End Argument Parser ---
 99 | 
100 | 
101 |     # --- Security Warning ---
102 |     if args.mode == 'record': # Show warning mainly for recording
103 |         warnings.warn(
104 |             "SECURITY WARNING: You are about to run an AI agent that interacts with the web based on "
105 |             "LLM instructions for recording test steps. Ensure the target environment is safe.",
106 |             UserWarning
107 |         )
108 |         print("\n" + "*"*70)
109 |         print("!!! AI WEB TESTING AGENT - RECORDER MODE !!!")
110 |         print("This agent interacts with websites to record automated tests.")
111 |         print(">> Ensure you target the correct environment (e.g., staging).")
112 |         print(">> Avoid recording actions involving highly sensitive production data.")
113 |         print(">> You will be prompted to confirm or override AI suggestions.")
114 |         print("Proceed with caution.")
115 |         print("*"*70 + "\n")
116 |     # --- End Security Warning ---
117 | 
118 | 
119 |     try:
120 |         # --- Configuration ---
121 |         api_key = load_api_key()
122 |         endpoint = load_api_base_url()
123 |         api_version = load_api_version()
124 |         model_name = load_llm_model()
125 |         if not os.path.exists("output"):
126 |             try:
127 |                 os.makedirs("output")
128 |                 logger.info("Created 'output' directory for screenshots and evidence.")
129 |             except OSError as e:
130 |                 logger.warning(f"Could not create 'output' directory: {e}. Saving evidence/screenshots might fail.")
131 | 
132 |                 
133 |         if args.mode == 'record':
134 |             logger.info("Starting in RECORD mode...")
135 |             HEADLESS_BROWSER = False # Recording MUST be non-headless
136 |             MAX_TEST_ITERATIONS = 50 # Allow more steps for recording complex flows
137 |             MAX_HISTORY_FOR_LLM = 10
138 |             MAX_STEP_RETRIES = 1 # Retries during recording are for AI suggestion refinement
139 | 
140 |             print("Running in interactive RECORD mode (Browser window is required).")
141 | 
142 | 
143 | 
144 | 
145 |             # --- Initialize Components ---
146 |             llm_client = LLMClient(provider=args.provider)
147 |             
148 |             automated = False
149 |             if args.automated == True:
150 |                 automated = True
151 |             recorder_agent = WebAgent(
152 |                 llm_client=llm_client,
153 |                 headless=HEADLESS_BROWSER, # Must be False
154 |                 max_iterations=MAX_TEST_ITERATIONS,
155 |                 max_history_length=MAX_HISTORY_FOR_LLM,
156 |                 max_retries_per_subtask=MAX_STEP_RETRIES,
157 |                 is_recorder_mode=True, # Add a flag to agent
158 |                 automated_mode=automated
159 |             )
160 | 
161 |             # --- Get Feature Description ---
162 |             print("\nEnter the feature or user flow you want to test.")
163 |             print("Examples:")
164 |             print("- go to https://practicetestautomation.com/practice-test-login/ and login with username as student and password as Password123 and verify if the login was successful")
165 |             print("- Navigate to 'https://example-shop.com', search for 'blue widget', add the first result to the cart, and verify the cart item count increases to 1 (selector: 'span#cart-count').")
166 |             print("- On 'https://form-page.com', fill the 'email' field with '[email protected]', check the 'terms' checkbox (id='terms-cb'), click submit, and verify the success message 'Form submitted!' is shown in 'div.status'.")
167 | 
168 |             feature_description = input("\nPlease enter the test case description: ")
169 | 
170 |             # --- Run the Test ---
171 |             if feature_description:
172 |                 # The run method now handles the recording loop
173 |                 recording_result = recorder_agent.record(feature_description) # Changed method name
174 | 
175 |                 print("\n" + "="*20 + " Recording Result " + "="*20)
176 |                 if recording_result.get("success"):
177 |                     print(f"Status: SUCCESS")
178 |                     print(f"Recording saved to: {recording_result.get('output_file')}")
179 |                     print(f"Total steps recorded: {recording_result.get('steps_recorded')}")
180 |                 else:
181 |                     print(f"Status: FAILED or ABORTED")
182 |                     print(f"Message: {recording_result.get('message')}")
183 |                 print("="*58)
184 | 
185 |             else:
186 |                 print("No test case description entered. Exiting.")
187 | 
188 |         elif args.mode == 'execute':
189 |             logger.info(f"Starting in EXECUTE mode for file: {args.file}")
190 |             HEADLESS_BROWSER = args.headless # Use flag for executor headless
191 |             PIXEL_MISMATCH_THRESHOLD = 0.01
192 |             heal_msg = f"Self-Healing: {'ENABLED (' + args.healing_mode + ' mode)' if args.enable_healing else 'DISABLED'}"
193 |             print(f"Running in EXECUTE mode ({'Headless' if args.headless else 'Visible Browser'}). {heal_msg}")
194 | 
195 |             
196 |             llm_client = LLMClient(provider=args.provider)
197 | 
198 |             # Executor doesn't need LLM client directly
199 |             executor = TestExecutor(
200 |                 llm_client=llm_client, # Pass the initialized client
201 |                 headless=args.headless,
202 |                 enable_healing=args.enable_healing,
203 |                 healing_mode=args.healing_mode,
204 |                 pixel_threshold=PIXEL_MISMATCH_THRESHOLD,
205 |                 get_performance=True,
206 |                 get_network_requests=True   
207 |                 # healing_retries can be added as arg if needed
208 |             )
209 |             test_result = executor.run_test(args.file)
210 | 
211 |             # --- Display Test Execution Results ---
212 |             print("\n" + "="*20 + " Execution Result " + "="*20)
213 |             print(f"Test File: {test_result.get('test_file', 'N/A')}")
214 |             print(f"Status: {test_result.get('status', 'UNKNOWN')}")
215 |             print(f"Duration: {test_result.get('duration_seconds', 'N/A')} seconds")
216 |             print(f"Message: {test_result.get('message', 'N/A')}")
217 |             print(f"Healing: {'ENABLED ('+test_result.get('healing_mode','N/A')+' mode)' if test_result.get('healing_enabled') else 'DISABLED'}")
218 |             
219 |             perf_timing = test_result.get("performance_timing")
220 |             if perf_timing:
221 |                  try:
222 |                       nav_start = perf_timing.get('navigationStart', 0)
223 |                       load_end = perf_timing.get('loadEventEnd', 0)
224 |                       dom_content_loaded = perf_timing.get('domContentLoadedEventEnd', 0)
225 |                       dom_interactive = perf_timing.get('domInteractive', 0)
226 | 
227 |                       if nav_start > 0: # Ensure navigationStart is valid
228 |                            print("\n--- Performance Metrics (Initial Load) ---")
229 |                            if load_end > nav_start: print(f"  Page Load Time (loadEventEnd): {(load_end - nav_start):,}ms")
230 |                            if dom_content_loaded > nav_start: print(f"  DOM Content Loaded (domContentLoadedEventEnd): {(dom_content_loaded - nav_start):,}ms")
231 |                            if dom_interactive > nav_start: print(f"  DOM Interactive: {(dom_interactive - nav_start):,}ms")
232 |                            print("-" * 20)
233 |                       else:
234 |                            print("\n--- Performance Metrics (Initial Load): navigationStart not captured ---")
235 |                  except Exception as perf_err:
236 |                      logger.warning(f"Could not process performance timing: {perf_err}")
237 |                      print("\n--- Performance Metrics: Error processing data ---")
238 |             # ------------------------------------
239 | 
240 |             # --- Network Request Summary ---
241 |             network_reqs = test_result.get("network_requests", [])
242 |             if network_reqs:
243 |                  print("\n--- Network Summary ---")
244 |                  total_reqs = len(network_reqs)
245 |                  http_error_reqs = len([r for r in network_reqs if (r.get('status', 0) or 0) >= 400])
246 |                  error_reqs = len([r for r in network_reqs if (r.get('status', 0) or 0) >= 400])
247 |                  slow_reqs = len([r for r in network_reqs if (r.get('duration_ms') or 0) > 1500]) # Example: > 1.5s
248 | 
249 |                  print(f"  Total Requests: {total_reqs}")
250 |                  if http_error_reqs > 0: print(f"  Requests >= 400 Status: {http_error_reqs}")
251 |                  if error_reqs > 0: print(f"  Requests >= 400 Status: {error_reqs}")
252 |                  if slow_reqs > 0: print(f"  Requests > 1500ms: {slow_reqs}")
253 |                  print("(See JSON report for full network details)")
254 |                  print("-" * 20)
255 |             
256 |             visual_results = test_result.get("visual_assertion_results", [])
257 |             if visual_results:
258 |                  print("\n--- Visual Assertion Results ---")
259 |                  for vr in visual_results:
260 |                      status = vr.get('status', 'UNKNOWN')
261 |                      override = " (LLM Override)" if vr.get('llm_override') else ""
262 |                      diff_percent = vr.get('pixel_difference_ratio', 0) * 100
263 |                      thresh_percent = vr.get('pixel_threshold', PIXEL_MISMATCH_THRESHOLD) * 100 # Use executor's default if needed
264 |                      print(f"- Step {vr.get('step_id')}, Baseline '{vr.get('baseline_id')}': {status}{override}")
265 |                      print(f"  Pixel Difference: {diff_percent:.4f}% (Threshold: {thresh_percent:.2f}%)")
266 |                      if status == 'FAIL':
267 |                          if vr.get('diff_image_path'):
268 |                              print(f"  Diff Image: {vr.get('diff_image_path')}")
269 |                          if vr.get('llm_reasoning'):
270 |                              print(f"  LLM Reasoning: {vr.get('llm_reasoning')}")
271 |                      elif vr.get('llm_override'): # Passed due to LLM
272 |                            if vr.get('llm_reasoning'):
273 |                              print(f"  LLM Reasoning: {vr.get('llm_reasoning')}")
274 | 
275 |                  print("-" * 20)
276 | 
277 |             # Display Healing Attempts Log
278 |             healing_attempts = test_result.get("healing_attempts", [])
279 |             if healing_attempts:
280 |                  print("\n--- Healing Attempts ---")
281 |                  for attempt in healing_attempts:
282 |                      outcome = "SUCCESS" if attempt.get('success') else "FAIL"
283 |                      mode = attempt.get('mode', 'N/A')
284 |                      print(f"- Step {attempt.get('step_id')}: Attempt {attempt.get('attempt')} ({mode} mode) - {outcome}")
285 |                      if outcome == "SUCCESS" and mode == "soft":
286 |                           print(f"  Old Selector: {attempt.get('failed_selector')}")
287 |                           print(f"  New Selector: {attempt.get('new_selector')}")
288 |                           print(f"  Reasoning: {attempt.get('reasoning', 'N/A')[:100]}...")
289 |                      elif outcome == "FAIL" and mode == "soft":
290 |                           print(f"  Failed Selector: {attempt.get('failed_selector')}")
291 |                           print(f"  Reasoning: {attempt.get('reasoning', 'N/A')[:100]}...")
292 |                      elif mode == "hard":
293 |                           print(f"  Triggered re-recording due to error: {attempt.get('error', 'N/A')[:100]}...")
294 |                  print("-" * 20)
295 | 
296 |             if test_result.get('status') == 'FAIL':
297 |                  print("-" * 15 + " Failure Details " + "-" * 15)
298 |                  failed_step_info = test_result.get('failed_step', {})
299 |                  print(f"Failed Step ID: {failed_step_info.get('step_id', 'N/A')}")
300 |                  print(f"Failed Step Description: {failed_step_info.get('description', 'N/A')}")
301 |                  print(f"Action: {failed_step_info.get('action', 'N/A')}")
302 |                  # Show the *last* selector tried if healing was attempted
303 |                  last_selector_tried = failed_step_info.get('selector') # Default to original
304 |                  last_failed_healing_attempt = next((a for a in reversed(healing_attempts) if a.get('step_id') == failed_step_info.get('step_id') and not a.get('success')), None)
305 |                  if last_failed_healing_attempt:
306 |                       last_selector_tried = last_failed_healing_attempt.get('failed_selector')
307 |                  print(f"Selector Used (Last Attempt): {last_selector_tried or 'N/A'}")
308 |                  print(f"Error: {test_result.get('error_details', 'N/A')}")
309 |                  if test_result.get('screenshot_on_failure'):
310 |                       print(f"Failure Screenshot: {test_result.get('screenshot_on_failure')}")
311 |                  # (Console message display remains the same)
312 |                  console_msgs = test_result.get("console_messages_on_failure", [])
313 |                  if console_msgs:
314 |                      print("\n--- Console Errors/Warnings (Recent): ---")
315 |                      for msg in console_msgs:
316 |                          msg_text = str(msg.get('text',''))
317 |                          print(f"- [{msg.get('type','UNKNOWN').upper()}] {msg_text[:250]}{'...' if len(msg_text) > 250 else ''}")
318 |                      total_err_warn = len([m for m in test_result.get("all_console_messages", []) if m.get('type') in ['error', 'warning']])
319 |                      if total_err_warn > len(console_msgs):
320 |                           print(f"... (Showing last {len(console_msgs)} of {total_err_warn} total errors/warnings. See JSON report for full logs)")
321 |                  else:
322 |                      print("\n--- No relevant console errors/warnings captured on failure. ---")
323 |             elif test_result.get('status') == 'PASS':
324 |                  print(f"Steps Executed: {test_result.get('steps_executed', 'N/A')}")
325 |             elif test_result.get('status') == 'HEALING_TRIGGERED':
326 |                  print(f"\nNOTICE: Hard Healing (re-recording) was triggered.")
327 |                  print(f"The original execution stopped at Step {test_result.get('failed_step', {}).get('step_id', 'N/A')}.")
328 |                  print(f"Check logs for the status and output file of the re-recording process.")
329 | 
330 | 
331 |             print("="*58)
332 | 
333 |             # --- Save Full Execution Results to JSON ---
334 |             try:
335 |                  base_name = os.path.splitext(os.path.basename(args.file))[0]
336 |                  result_filename = os.path.join("output", f"execution_result_{base_name}_{time.strftime('%Y%m%d_%H%M%S')}.json")
337 |                  with open(result_filename, 'w', encoding='utf-8') as f:
338 |                      json.dump(test_result, f, indent=2, ensure_ascii=False)
339 |                  print(f"\nFull execution result details saved to: {result_filename}")
340 |             except Exception as save_err:
341 |                  logger.error(f"Failed to save full execution result JSON: {save_err}")
342 | 
343 |         elif args.mode == 'discover':
344 |             warnings.warn(
345 |                 "SECURITY WARNING: You are about to run an AI agent that interacts with the web based on "
346 |                 "LLM instructions or crawling logic. Ensure the target environment is safe.",
347 |                 UserWarning
348 |             )   
349 |             print("!!! AI WEB TESTING AGENT - DISCOVERY MODE !!!")
350 |             print("This agent will crawl the website starting from the provided URL.")
351 |             print(">> It will analyze pages and ask an LLM for test step ideas.")
352 |             print(">> Ensure you have permission to crawl the target website.")
353 |             print(f">> Crawling will be limited to the domain of '{args.url}' and max {args.max_pages} pages.")
354 |             print("Proceed with caution.")
355 |             print("*"*70 + "\n")
356 |             logger.info(f"Starting in DISCOVER mode for URL: {args.url}")
357 |             HEADLESS_BROWSER = args.headless # Use the general headless flag
358 |             print(f"Running in DISCOVER mode ({'Headless' if HEADLESS_BROWSER else 'Visible Browser'}).")
359 |             print(f"Starting URL: {args.url}")
360 |             print(f"Max pages to crawl: {args.max_pages}")
361 | 
362 |             # Initialize Components
363 |             llm_client = LLMClient(provider=args.provider)
364 |             crawler = CrawlerAgent(
365 |                 llm_client=llm_client,
366 |                 headless=HEADLESS_BROWSER
367 |             )
368 | 
369 |             # Run Discovery
370 |             discovery_result = crawler.crawl_and_suggest(args.url, args.max_pages)
371 | 
372 |             # Display Discovery Results
373 |             print("\n" + "="*20 + " Discovery Result " + "="*20)
374 |             print(f"Status: {'SUCCESS' if discovery_result.get('success') else 'FAILED'}")
375 |             print(f"Message: {discovery_result.get('message', 'N/A')}")
376 |             print(f"Start URL: {discovery_result.get('start_url', 'N/A')}")
377 |             print(f"Base Domain: {discovery_result.get('base_domain', 'N/A')}")
378 |             print(f"Pages Visited: {discovery_result.get('pages_visited', 0)}")
379 | 
380 |             discovered_steps_map = discovery_result.get('discovered_steps', {})
381 |             print(f"Pages with Suggested Steps: {len(discovered_steps_map)}")
382 |             print("-" * 58)
383 | 
384 |             if discovered_steps_map:
385 |                 print("\n--- Suggested Test Steps per Page ---")
386 |                 for page_url, steps in discovered_steps_map.items():
387 |                     print(f"\n[Page: {page_url}]")
388 |                     if steps:
389 |                         for i, step_desc in enumerate(steps):
390 |                             print(f"  {i+1}. {step_desc}")
391 |                     else:
392 |                         print("  (No specific steps suggested by LLM for this page)")
393 |             else:
394 |                 print("\nNo test step suggestions were generated.")
395 | 
396 |             print("="*58)
397 | 
398 |             # Save Full Discovery Results to JSON
399 |             if discovery_result.get('success'): # Only save if crawl succeeded somewhat
400 |                 try:
401 |                      # Generate a filename based on the domain
402 |                      domain = discovery_result.get('base_domain', 'unknown_domain')
403 |                      # Sanitize domain for filename
404 |                      safe_domain = "".join(c if c.isalnum() else "_" for c in domain)
405 |                      result_filename = os.path.join("output", f"discovery_results_{safe_domain}_{time.strftime('%Y%m%d_%H%M%S')}.json")
406 |                      with open(result_filename, 'w', encoding='utf-8') as f:
407 |                          json.dump(discovery_result, f, indent=2, ensure_ascii=False)
408 |                      print(f"\nFull discovery result details saved to: {result_filename}")
409 |                 except Exception as save_err:
410 |                      logger.error(f"Failed to save full discovery result JSON: {save_err}")
411 | 
412 |         elif args.mode == 'auth':
413 |             # Ensure output directory exists
414 |             os.makedirs("output", exist_ok=True)
415 | 
416 |             # --- IMPORTANT: Initialize your LLM Client here ---
417 |             # Replace with your actual LLM provider and initialization
418 |             try:
419 |                 # Example using Gemini (replace with your actual setup)
420 |                 # Ensure GOOGLE_API_KEY is set as an environment variable if using GeminiClient defaults
421 |                 logger.info(f"Using LLM Provider: {args.provider}")
422 |                 llm = LLMClient(provider=args.provider)
423 |                 logger.info("LLM Client initialized.")
424 |             except ValueError as e:
425 |                 logger.error(f"❌ Failed to initialize LLM Client: {e}. Cannot proceed.")
426 |                 llm = None
427 |             except Exception as e:
428 |                 logger.error(f"❌ An unexpected error occurred initializing LLM Client: {e}. Cannot proceed.", exc_info=True)
429 |                 llm = None
430 |             # ------------------------------------------------
431 | 
432 |             if llm:
433 |                 success = record_selectors_and_save_auth_state(llm, args.url, args.file)
434 |                 if success:
435 |                     print(f"\n--- Authentication state generation completed successfully. ---")
436 |                 else:
437 |                     print(f"\n--- Authentication state generation failed. Check logs and screenshots in 'output/'. ---")
438 |             else:
439 |                 print("\n--- Could not initialize LLM Client. Aborting authentication state generation. ---")
440 | 
441 |         elif args.mode == 'security':
442 |             logging.info("--- Starting Phase 1: Security Scanning ---")
443 |             all_findings = []
444 |             # 1. Run ZAP Scan
445 |             # logging.info("--- Running ZAP Scan ---")
446 |             # if not args.zap_api_key:
447 |             #     logging.warning("ZAP API key not provided. ZAP scan might fail if API key is required.")
448 |             # zap_findings = run_zap_scan(
449 |             #     target_url=args.url,
450 |             #     zap_address=args.zap_address,
451 |             #     zap_api_key=args.zap_api_key,
452 |             #     spider_timeout=args.zap_spider_timeout,
453 |             #     scan_timeout=args.zap_scan_timeout
454 |             # )
455 |             # if zap_findings:
456 |             #     logging.info(f"Completed ZAP Scan. Found {len(zap_findings)} alerts.")
457 |             #     all_findings.extend(zap_findings)
458 |             #     save_report(zap_findings, "zap", args.output_dir, "scan_results")
459 |             # else:
460 |             #     logging.warning("ZAP scan completed with no findings or failed.")
461 | 
462 |             # 2. Run Nuclei Scan
463 |             # logging.info("--- Running Nuclei Scan ---")
464 |             # nuclei_findings = run_nuclei(
465 |             #     target_url=args.url,
466 |             #     templates=args.nuclei_templates,
467 |             #     output_dir=args.output_dir,
468 |             #     timeout=args.nuclei_timeout
469 |             # )
470 |             # if nuclei_findings:
471 |             #     logging.info(f"Completed Nuclei Scan. Found {len(nuclei_findings)} potential issues.")
472 |             #     all_findings.extend(nuclei_findings)
473 |             #     # Nuclei output was already saved by the function, but we can save the parsed list again if needed
474 |             #     # save_report(nuclei_findings, "nuclei", args.output_dir, "scan_results_parsed")
475 |             # else:
476 |             #     logging.warning("Nuclei scan completed with no findings or failed.")
477 | 
478 |             # 3. Run Semgrep Scan (if code path provided)
479 |             # 3. Run Semgrep Scan (if code path provided)
480 |             if args.code_path:
481 |                 logging.info("--- Running Semgrep Scan ---")
482 |                 semgrep_findings = run_semgrep(
483 |                     code_path=args.code_path,
484 |                     config=args.semgrep_config,
485 |                     output_dir=args.output_dir,
486 |                     timeout=args.semgrep_timeout
487 |                 )
488 |                 if semgrep_findings:
489 |                     logging.info(f"Completed Semgrep Scan. Found {len(semgrep_findings)} potential issues.")
490 |                     all_findings.extend(semgrep_findings)
491 |                     # Semgrep output was already saved, save parsed list if desired
492 |                     # save_report(semgrep_findings, "semgrep", args.output_dir, "scan_results_parsed")
493 |                 else:
494 |                     logging.warning("Semgrep scan completed with no findings or failed.")
495 |             else:
496 |                 logging.info("Skipping Semgrep scan as --code-path was not provided.")
497 | 
498 |             logging.info("--- Phase 1: Security Scanning Complete ---")
499 |             
500 |             logging.info("--- Starting Phase 2: Consolidating Results ---")
501 | 
502 |             logging.info(f"Total findings aggregated from all tools (future): {len(all_findings)}")
503 | 
504 |             # Save the consolidated report
505 |             consolidated_report_path = save_report(all_findings, "consolidated", args.output_dir, "consolidated_scan_results")
506 | 
507 |             if consolidated_report_path:
508 |                 logging.info(f"Consolidated report saved to: {consolidated_report_path}")
509 |                 print(f"\nConsolidated report saved to: {consolidated_report_path}") # Also print to stdout
510 |             else:
511 |                 logging.error("Failed to save the consolidated report.")
512 | 
513 |             logging.info("--- Phase 2: Consolidation Complete ---")
514 |             logging.info("--- Security Automation Script Finished ---")
515 | 
516 | 
517 | 
518 | 
519 |     except ValueError as e:
520 |          logger.error(f"Configuration or Input error: {e}")
521 |          print(f"Error: {e}")
522 |     except ImportError as e:
523 |          logger.error(f"Import error: {e}. Make sure requirements are installed and paths correct.")
524 |          print(f"Import Error: {e}. Please check installation.")
525 |     except Exception as e:
526 |         logger.critical(f"An unexpected error occurred in main: {e}", exc_info=True)
527 |         print(f"An critical unexpected error occurred: {e}")
528 | 
```

--------------------------------------------------------------------------------
/src/browser/panel/panel.py:
--------------------------------------------------------------------------------

```python
  1 | # /src/browser/panel/panel.py
  2 | import threading
  3 | import logging
  4 | from typing import Optional, Dict, Any, List
  5 | from patchright.sync_api import sync_playwright, Page, Browser, Playwright, TimeoutError as PlaywrightTimeoutError
  6 | 
  7 | logger = logging.getLogger(__name__)
  8 | 
  9 | RECORDER_PANEL_JS = """
 10 | () => {
 11 |     const PANEL_ID = 'bw-recorder-panel';
 12 |     const INPUT_ID = 'bw-recorder-param-input'; // Used for general param input now
 13 |     const PARAM_BTN_ID = 'bw-recorder-param-button'; // Button next to general param input
 14 |     const PARAM_CONT_ID = 'bw-recorder-param-container'; // Container for single param input
 15 |     const ASSERT_PARAM_INPUT1_ID = 'bw-assert-param1';
 16 |     const ASSERT_PARAM_INPUT2_ID = 'bw-assert-param2';
 17 |     const ASSERT_PARAM_CONT_ID = 'bw-assert-param-container'; // Container for assertion-specific params
 18 | 
 19 |     // --- Function to create or get the panel ---
 20 |     function getOrCreatePanel() {
 21 |         let panel = document.getElementById(PANEL_ID);
 22 |         if (!panel) {
 23 |             panel = document.createElement('div');
 24 |             panel.id = PANEL_ID;
 25 |             // Basic Styling (customize as needed)
 26 |             Object.assign(panel.style, {
 27 |                 position: 'fixed',
 28 |                 bottom: '10px',
 29 |                 right: '10px',
 30 |                 padding: '10px',
 31 |                 background: 'rgba(40, 40, 40, 0.9)',
 32 |                 color: 'white',
 33 |                 border: '1px solid #ccc',
 34 |                 borderRadius: '5px',
 35 |                 zIndex: '2147483647', // Max z-index
 36 |                 fontFamily: 'sans-serif',
 37 |                 fontSize: '12px',
 38 |                 boxShadow: '0 2px 5px rgba(0,0,0,0.3)',
 39 |                 display: 'none', // Initially hidden
 40 |                 pointerEvents: 'none'
 41 |             });
 42 |             document.body.appendChild(panel);
 43 |         }
 44 |         return panel;
 45 |     }
 46 |     
 47 |     // --- Helper to Set Button Listeners ---
 48 |     // (choiceValue is what window._recorder_user_choice will be set to)
 49 |     function setChoiceOnClick(buttonId, choiceValue) {
 50 |         const btn = document.getElementById(buttonId);
 51 |         if (btn) {
 52 |             btn.onclick = () => { window._recorder_user_choice = choiceValue; };
 53 |         } else {
 54 |              console.warn(`[Recorder Panel] Button with ID ${buttonId} not found for listener.`);
 55 |         }
 56 |     }
 57 |     
 58 |     // State 1: Confirm/Override Assertion Target
 59 |     window._recorder_showAssertionTargetPanel = (plannedDesc, suggestedSelector) => {
 60 |         const panel = getOrCreatePanel();
 61 |         const selectorDisplay = suggestedSelector ? `<code>${suggestedSelector.substring(0, 100)}...</code>` : '<i>AI could not suggest a target.</i>';
 62 |         panel.innerHTML = `
 63 |             <div style="margin-bottom: 5px; font-weight: bold; pointer-events: auto;">Define Assertion:</div>
 64 |             <div style="margin-bottom: 8px; max-width: 300px; word-wrap: break-word; pointer-events: auto;">${plannedDesc}</div>
 65 |             <div style="margin-bottom: 5px; font-style: italic; pointer-events: auto;">Suggested Target Selector: ${selectorDisplay}</div>
 66 |             <button id="bw-assert-confirm-target" style="margin: 2px; padding: 3px 6px; pointer-events: auto;" ${!suggestedSelector ? 'disabled' : ''}>Use Suggested</button>
 67 |             <button id="bw-assert-override-target" style="margin: 2px; padding: 3px 6px; pointer-events: auto;">Click New Target</button>
 68 |             <button id="bw-assert-skip" style="margin: 2px; padding: 3px 6px; pointer-events: auto;">Skip Assertion</button>
 69 |             <button id="bw-abort-btn" style="margin: 2px; padding: 3px 6px; background-color: #d9534f; color: white; border: none; pointer-events: auto;">Abort</button>
 70 |         `;
 71 |         window._recorder_user_choice = undefined; // Reset choice
 72 |         setChoiceOnClick('bw-assert-confirm-target', 'confirm_target');
 73 |         setChoiceOnClick('bw-assert-override-target', 'override_target');
 74 |         setChoiceOnClick('bw-assert-skip', 'skip');
 75 |         setChoiceOnClick('bw-abort-btn', 'abort');
 76 |         panel.style.display = 'block';
 77 |         console.log('[Recorder Panel] Assertion Target Panel Shown.');
 78 |     };
 79 |     
 80 |     // State 2: Select Assertion Type
 81 |     window._recorder_showAssertionTypePanel = (targetSelector) => {
 82 |         const panel = getOrCreatePanel();
 83 |         panel.innerHTML = `
 84 |              <div style="margin-bottom: 5px; font-weight: bold; pointer-events: auto;">Select Assertion Type:</div>
 85 |              <div style="margin-bottom: 8px; font-size: 11px; pointer-events: auto;">Target: <code>${targetSelector.substring(0, 100)}...</code></div>
 86 |              <div style="display: flex; flex-wrap: wrap; gap: 5px; pointer-events: auto;">
 87 |                  <button id="type-contains" style="padding: 3px 6px; pointer-events: auto;">Text Contains</button>
 88 |                  <button id="type-equals" style="padding: 3px 6px; pointer-events: auto;">Text Equals</button>
 89 |                  <button id="type-visible" style="padding: 3px 6px; pointer-events: auto;">Is Visible</button>
 90 |                  <button id="type-hidden" style="padding: 3px 6px; pointer-events: auto;">Is Hidden</button>
 91 |                  <button id="type-attr" style="padding: 3px 6px; pointer-events: auto;">Attribute Equals</button>
 92 |                  <button id="type-count" style="padding: 3px 6px; pointer-events: auto;">Element Count</button>
 93 |                  <button id="type-checked" style="padding: 3px 6px; pointer-events: auto;">Is Checked</button>
 94 |                  <button id="type-not-checked" style="padding: 3px 6px; pointer-events: auto;">Not Checked</button>
 95 |              </div>
 96 |              <hr style="margin: 8px 0; border-top: 1px solid #555;">
 97 |              <button id="bw-assert-back-target" style="margin-right: 5px; padding: 3px 6px; pointer-events: auto;">< Back (Target)</button>
 98 |              <button id="bw-assert-skip" style="margin-right: 5px; padding: 3px 6px; pointer-events: auto;">Skip Assertion</button>
 99 |              <button id="bw-abort-btn" style="padding: 3px 6px; background-color: #d9534f; color: white; border: none; pointer-events: auto;">Abort</button>
100 |         `;
101 |         window._recorder_user_choice = undefined; // Reset choice
102 |         // Set listeners for type selection
103 |         setChoiceOnClick('type-contains', 'select_type_text_contains');
104 |         setChoiceOnClick('type-equals', 'select_type_text_equals');
105 |         setChoiceOnClick('type-visible', 'select_type_visible');
106 |         setChoiceOnClick('type-hidden', 'select_type_hidden');
107 |         setChoiceOnClick('type-attr', 'select_type_attribute_equals');
108 |         setChoiceOnClick('type-count', 'select_type_element_count');
109 |         setChoiceOnClick('type-checked', 'select_type_checked');
110 |         setChoiceOnClick('type-not-checked', 'select_type_not_checked');
111 |         // Other controls
112 |         setChoiceOnClick('bw-assert-back-target', 'back_to_target');
113 |         setChoiceOnClick('bw-assert-skip', 'skip');
114 |         setChoiceOnClick('bw-abort-btn', 'abort');
115 |         panel.style.display = 'block';
116 |         console.log('[Recorder Panel] Assertion Type Panel Shown.');
117 |     };
118 | 
119 |     // State 3: Enter Assertion Parameters
120 |     window._recorder_showAssertionParamsPanel = (targetSelector, assertionType, paramLabels) => {
121 |         // paramLabels is an array like ['Expected Text'] or ['Attribute Name', 'Expected Value'] or ['Expected Count']
122 |         const panel = getOrCreatePanel();
123 |         let inputHTML = '';
124 |         if (paramLabels.length === 1) {
125 |             inputHTML = `<label for="${ASSERT_PARAM_INPUT1_ID}" style="margin-right: 5px; pointer-events: auto;">${paramLabels[0]}:</label>
126 |                          <input type="text" id="${ASSERT_PARAM_INPUT1_ID}" style="padding: 2px 4px; width: 180px; pointer-events: auto;">`;
127 |         } else if (paramLabels.length === 2) {
128 |              inputHTML = `<div style="margin-bottom: 3px;">
129 |                               <label for="${ASSERT_PARAM_INPUT1_ID}" style="display: inline-block; width: 100px; pointer-events: auto;">${paramLabels[0]}:</label>
130 |                               <input type="text" id="${ASSERT_PARAM_INPUT1_ID}" style="padding: 2px 4px; width: 120px; pointer-events: auto;">
131 |                           </div>
132 |                           <div>
133 |                               <label for="${ASSERT_PARAM_INPUT2_ID}" style="display: inline-block; width: 100px; pointer-events: auto;">${paramLabels[1]}:</label>
134 |                               <input type="text" id="${ASSERT_PARAM_INPUT2_ID}" style="padding: 2px 4px; width: 120px; pointer-events: auto;">
135 |                           </div>`;
136 |         }
137 | 
138 |         panel.innerHTML = `
139 |              <div style="margin-bottom: 5px; font-weight: bold; pointer-events: auto;">Enter Parameters:</div>
140 |              <div style="margin-bottom: 3px; font-size: 11px; pointer-events: auto;">Target: <code>${targetSelector.substring(0, 60)}...</code></div>
141 |              <div style="margin-bottom: 8px; font-size: 11px; pointer-events: auto;">Assertion: ${assertionType}</div>
142 |              <div id="${ASSERT_PARAM_CONT_ID}" style="margin-bottom: 8px; pointer-events: auto;">
143 |                 ${inputHTML}
144 |              </div>
145 |              <button id="bw-assert-record" style="margin-right: 5px; padding: 3px 6px; pointer-events: auto;">Record Assertion</button>
146 |              <button id="bw-assert-back-type" style="margin-right: 5px; padding: 3px 6px; pointer-events: auto;">< Back (Type)</button>
147 |              <button id="bw-abort-btn" style="padding: 3px 6px; background-color: #d9534f; color: white; border: none; pointer-events: auto;">Abort</button>
148 |         `;
149 |         window._recorder_user_choice = undefined; // Reset choice
150 |         setChoiceOnClick('bw-assert-record', 'submit_params');
151 |         setChoiceOnClick('bw-assert-back-type', 'back_to_type');
152 |         setChoiceOnClick('bw-abort-btn', 'abort');
153 |         panel.style.display = 'block';
154 |         // Auto-focus the first input if possible
155 |         const firstInput = document.getElementById(ASSERT_PARAM_INPUT1_ID);
156 |         if (firstInput) {
157 |              setTimeout(() => firstInput.focus(), 50); // Short delay
158 |         }
159 |         console.log('[Recorder Panel] Assertion Params Panel Shown.');
160 |     };
161 |     
162 |     // State 4: Verification Review
163 |     window._recorder_showVerificationReviewPanel = (args) => {
164 |         const { plannedDesc, aiVerified, aiReasoning, assertionType, parameters, selector } = args;
165 |         const panel = getOrCreatePanel();
166 |         let detailsHTML = '';
167 |         let recordButtonDisabled = true; // Disable record button by default
168 | 
169 |         // --- Build Details Section based on AI Result ---
170 |         if (aiVerified) {
171 |             // Check if we have enough info to actually record the assertion
172 |             const canRecord = assertionType && selector;
173 |             recordButtonDisabled = !canRecord;
174 | 
175 |             detailsHTML += `<div style="margin-bottom: 3px; pointer-events: auto;">Assertion: <code>${assertionType || 'N/A'}</code></div>`;
176 |             detailsHTML += `<div style="margin-bottom: 3px; pointer-events: auto;">Selector: <code>${selector ? selector.substring(0, 100) + '...' : 'MISSING!'}</code></div>`;
177 |             // Safely format parameters (convert object to string)
178 |             let paramsString = 'None';
179 |             if (parameters && Object.keys(parameters).length > 0) {
180 |                  try { paramsString = JSON.stringify(parameters); } catch(e){ paramsString = '{...}'; }
181 |             }
182 |             detailsHTML += `<div style="margin-bottom: 5px; pointer-events: auto;">Parameters: <code>${paramsString}</code></div>`;
183 |              if (!canRecord) {
184 |                 detailsHTML += `<div style="color: #ffcc00; font-size: 11px; pointer-events: auto;">Warning: Cannot record assertion directly (missing type or selector from AI). Choose Manual or Skip.</div>`;
185 |             }
186 |         } else {
187 |             // Verification failed
188 |              detailsHTML += `<div style="color: #ffdddd; pointer-events: auto;">AI could not verify the condition.</div>`;
189 |         }
190 | 
191 | 
192 |         panel.innerHTML = `
193 |             <div style="margin-bottom: 5px; font-weight: bold; pointer-events: auto;">AI Verification Review:</div>
194 |             <div style="margin-bottom: 8px; max-width: 300px; word-wrap: break-word; pointer-events: auto;">${plannedDesc}</div>
195 |             <div style="margin-bottom: 5px; font-style: italic; color: ${aiVerified ? '#ccffcc' : '#ffdddd'}; pointer-events: auto;">
196 |                 AI Result: ${aiVerified ? 'PASSED' : 'FAILED'}
197 |             </div>
198 |             <div style="margin-bottom: 8px; font-size: 11px; max-height: 60px; overflow-y: auto; border: 1px dashed #666; padding: 3px; pointer-events: auto;">
199 |                 AI Reasoning: ${aiReasoning || 'N/A'}
200 |             </div>
201 |             ${detailsHTML}
202 |             <hr style="margin: 8px 0; border-top: 1px solid #555;">
203 |             <button id="bw-verify-record" style="margin: 2px; padding: 3px 6px; pointer-events: auto;" ${recordButtonDisabled ? 'disabled title="Cannot record directly, missing info from AI"' : ''}>Record AI Assertion</button>
204 |             <button id="bw-verify-manual" style="margin: 2px; padding: 3px 6px; pointer-events: auto;">Define Manually</button>
205 |             <button id="bw-verify-skip" style="margin: 2px; padding: 3px 6px; pointer-events: auto;">Skip Step</button>
206 |             <button id="bw-abort-btn" style="margin: 2px; padding: 3px 6px; background-color: #d9534f; color: white; border: none; pointer-events: auto;">Abort</button>
207 |              <!-- Re-use existing parameterization container, initially hidden -->
208 |              <div id="${PARAM_CONT_ID}" style="margin-top: 8px; display: none; pointer-events: auto;">
209 |                  <input type="text" id="${INPUT_ID}" placeholder="Parameter Name (optional)" style="padding: 2px 4px; width: 150px; margin-right: 5px; pointer-events: auto;">
210 |                  <button id="${PARAM_BTN_ID}" style="padding: 3px 6px; pointer-events: auto;">Set Param & Record</button>
211 |              </div>
212 |         `;
213 |         window._recorder_user_choice = undefined; // Reset choice
214 |         window._recorder_parameter_name = undefined; // Reset param name
215 | 
216 |         // Set listeners
217 |         setChoiceOnClick('bw-verify-record', 'record_ai');
218 |         setChoiceOnClick('bw-verify-manual', 'define_manual');
219 |         setChoiceOnClick('bw-verify-skip', 'skip');
220 |         setChoiceOnClick('bw-abort-btn', 'abort');
221 |         // Listener for the parameterization button (same as before)
222 |         const paramBtn = document.getElementById(PARAM_BTN_ID);
223 |         if (paramBtn) {
224 |             paramBtn.onclick = () => {
225 |                 const inputVal = document.getElementById(INPUT_ID).value.trim();
226 |                 window._recorder_parameter_name = inputVal ? inputVal : null;
227 |                 window._recorder_user_choice = 'parameterized'; // Special choice
228 |             };
229 |         }
230 | 
231 |         panel.style.display = 'block';
232 |         console.log('[Recorder Panel] Verification Review Panel Shown.');
233 |     };
234 | 
235 |     // Function to retrieve assertion parameters
236 |     window._recorder_getAssertionParams = (count) => {
237 |         const params = {};
238 |         const input1 = document.getElementById(ASSERT_PARAM_INPUT1_ID);
239 |         if (input1) params.param1 = input1.value;
240 |         if (count > 1) {
241 |              const input2 = document.getElementById(ASSERT_PARAM_INPUT2_ID);
242 |              if (input2) params.param2 = input2.value;
243 |         }
244 |         console.log('[Recorder Panel] Retrieved assertion params:', params);
245 |         return params;
246 |     };
247 | 
248 |     // --- Function to update panel content ---
249 |     window._recorder_showPanel = (stepDescription, suggestionText) => {
250 |         const panel = getOrCreatePanel();
251 |         panel.innerHTML = `
252 |             <div style="margin-bottom: 5px; font-weight: bold; pointer-events: auto;">Next Step:</div> <!-- Re-enable for text selection if needed -->
253 |             <div style="margin-bottom: 8px; max-width: 300px; word-wrap: break-word; pointer-events: auto;">${stepDescription}</div>
254 |             <div style="margin-bottom: 5px; font-style: italic; pointer-events: auto;">AI Suggests: ${suggestionText}</div>
255 |             <button id="bw-accept-btn" style="margin-right: 5px; padding: 3px 6px; pointer-events: auto;">Accept Suggestion</button> <!-- <<< Re-enable pointer events for buttons -->
256 |             <button id="bw-skip-btn" style="margin-right: 5px; padding: 3px 6px; pointer-events: auto;">Skip Step</button> <!-- <<< Re-enable pointer events for buttons -->
257 |             <button id="bw-abort-btn" style="padding: 3px 6px; background-color: #d9534f; color: white; border: none; pointer-events: auto;">Abort</button> <!-- <<< Re-enable pointer events for buttons -->
258 |             <div id="${PARAM_CONT_ID}" style="margin-top: 8px; display: none; pointer-events: auto;"> <!-- <<< Re-enable pointer events for container -->
259 |                  <input type="text" id="${INPUT_ID}" placeholder="Parameter Name (optional)" style="padding: 2px 4px; width: 150px; margin-right: 5px; pointer-events: auto;"> <!-- <<< Re-enable pointer events for input -->
260 |                  <button id="${PARAM_BTN_ID}" style="padding: 3px 6px; pointer-events: auto;">Set Param & Record</button> <!-- <<< Re-enable pointer events for buttons -->
261 |             </div>
262 |         `;
263 | 
264 |         // --- Attach Button Listeners ---
265 |         // Reset choice flag before showing
266 |         window._recorder_user_choice = undefined;
267 |         window._recorder_parameter_name = undefined;
268 | 
269 |         document.getElementById('bw-accept-btn').onclick = () => { window._recorder_user_choice = 'accept'; };
270 |         document.getElementById('bw-skip-btn').onclick = () => { window._recorder_user_choice = 'skip'; /* hidePanel(); */ }; // Optionally hide immediately
271 |         document.getElementById('bw-abort-btn').onclick = () => { window._recorder_user_choice = 'abort'; /* hidePanel(); */ };
272 |         document.getElementById(PARAM_BTN_ID).onclick = () => {
273 |             const inputVal = document.getElementById(INPUT_ID).value.trim();
274 |             window._recorder_parameter_name = inputVal ? inputVal : null; // Store null if empty
275 |             window._recorder_user_choice = 'parameterized'; // Special choice for parameterization submit
276 |             // Don't hide panel here, Python side handles it after retrieving value
277 |         };
278 | 
279 |         panel.style.display = 'block'; // Make panel visible
280 |         console.log('[Recorder Panel] Panel shown.');
281 |     };
282 | 
283 |     // --- Function to hide the panel ---
284 |     window._recorder_hidePanel = () => {
285 |         const panel = document.getElementById(PANEL_ID);
286 |         if (panel) {
287 |             panel.style.display = 'none';
288 |             console.log('[Recorder Panel] Panel hidden.');
289 |         }
290 |          // Also reset choice on hide just in case
291 |         window._recorder_user_choice = undefined;
292 |         window._recorder_parameter_name = undefined;
293 |     };
294 | 
295 |     // --- Function to show parameterization UI ---
296 |     window._recorder_showParamUI = (defaultValue) => {
297 |          const paramContainer = document.getElementById(PARAM_CONT_ID);
298 |          const inputField = document.getElementById(INPUT_ID);
299 |          const acceptBtn = document.getElementById('bw-accept-btn');
300 |          if(paramContainer && inputField && acceptBtn) {
301 |              inputField.value = ''; // Clear previous value
302 |              inputField.setAttribute('placeholder', `Param Name for '${defaultValue.substring(0,20)}...' (optional)`);
303 |              paramContainer.style.display = 'block';
304 |              // Hide the original "Accept" button, show param button
305 |              acceptBtn.style.display = 'none';
306 |              document.getElementById(PARAM_BTN_ID).style.display = 'inline-block'; // Ensure param button is visible
307 |              console.log('[Recorder Panel] Parameterization UI shown.');
308 |              return true;
309 |          }
310 |          console.error('[Recorder Panel] Could not find parameterization elements.');
311 |          return false;
312 |      };
313 | 
314 |     // --- Function to remove the panel ---
315 |     window._recorder_removePanel = () => {
316 |         const panel = document.getElementById(PANEL_ID);
317 |         if (panel) {
318 |             panel.remove();
319 |             console.log('[Recorder Panel] Panel removed.');
320 |         }
321 |         // Clean up global flags
322 |         delete window._recorder_user_choice;
323 |         delete window._recorder_parameter_name;
324 |         delete window._recorder_showPanel;
325 |         delete window._recorder_hidePanel;
326 |         delete window._recorder_showParamUI;
327 |         delete window._recorder_removePanel;
328 |     };
329 | 
330 |     return true; // Indicate script injection success
331 | }
332 | """
333 | 
334 | 
335 | class Panel:
336 |     """Deals with panel injected into browser in manual mode"""
337 |     def __init__(self, headless=True, page=None):
338 |         self._recorder_ui_injected = False # Track if UI script is injected
339 |         self._panel_interaction_lock = threading.Lock() # Prevent race conditions waiting for panel
340 |         self.headless = headless
341 |         self.page = page
342 |         
343 |         # inject ui panel onto the browser
344 |     def inject_recorder_ui_scripts(self):
345 |         """Injects the JS functions for the recorder UI panel."""
346 |         if self.headless: return # No UI in headless
347 |         if not self.page:
348 |             logger.error("Page not initialized. Cannot inject recorder UI.")
349 |             return False
350 |         if self._recorder_ui_injected:
351 |             logger.debug("Recorder UI scripts already injected.")
352 |             return True
353 |         try:
354 |             self.page.evaluate(RECORDER_PANEL_JS)
355 |             self._recorder_ui_injected = True
356 |             logger.info("Recorder UI panel JavaScript injected successfully.")
357 |             return True
358 |         except Exception as e:
359 |             logger.error(f"Failed to inject recorder UI panel JS: {e}", exc_info=True)
360 |             return False
361 |         
362 |     def show_verification_review_panel(self, planned_desc: str, verification_result: Dict[str, Any]):
363 |         """Shows the panel for reviewing AI verification results."""
364 |         if self.headless or not self.page: return
365 |         try:
366 |             # Extract data needed by the JS function
367 |             args = {
368 |                 "plannedDesc": planned_desc,
369 |                 "aiVerified": verification_result.get('verified', False),
370 |                 "aiReasoning": verification_result.get('reasoning', 'N/A'),
371 |                 "assertionType": verification_result.get('assertion_type'),
372 |                 "parameters": verification_result.get('parameters', {}),
373 |                 "selector": verification_result.get('verification_selector') # Use the final selector
374 |             }
375 | 
376 |             js_script = f"""
377 |             (args) => {{
378 |                 ({RECORDER_PANEL_JS})(); // Ensure functions are defined
379 |                 if (window._recorder_showVerificationReviewPanel) {{
380 |                     window._recorder_showVerificationReviewPanel(args);
381 |                 }} else {{ console.error('Verification review panel function not defined!'); }}
382 |             }}"""
383 |             self.page.evaluate(js_script, args)
384 |         except Exception as e:
385 |             logger.error(f"Failed to show verification review panel: {e}", exc_info=True)
386 |     
387 |     def show_assertion_target_panel(self, planned_desc: str, suggested_selector: Optional[str]):
388 |         """Shows the panel for confirming/overriding the assertion target."""
389 |         if self.headless or not self.page: return
390 |         try:
391 |             js_script = f"""
392 |             (args) => {{
393 |                 ({RECORDER_PANEL_JS})(); // Ensure functions are defined
394 |                 if (window._recorder_showAssertionTargetPanel) {{
395 |                     window._recorder_showAssertionTargetPanel(args.plannedDesc, args.suggestedSelector);
396 |                 }} else {{ console.error('Assertion target panel function not defined!'); }}
397 |             }}"""
398 |             self.page.evaluate(js_script, {"plannedDesc": planned_desc, "suggestedSelector": suggested_selector})
399 |         except Exception as e:
400 |             logger.error(f"Failed to show assertion target panel: {e}", exc_info=True)
401 | 
402 |     def show_assertion_type_panel(self, target_selector: str):
403 |         """Shows the panel for selecting the assertion type."""
404 |         if self.headless or not self.page: return
405 |         try:
406 |             js_script = f"""
407 |             (args) => {{
408 |                 ({RECORDER_PANEL_JS})(); // Ensure functions are defined
409 |                 if (window._recorder_showAssertionTypePanel) {{
410 |                     window._recorder_showAssertionTypePanel(args.targetSelector);
411 |                 }} else {{ console.error('Assertion type panel function not defined!'); }}
412 |             }}"""
413 |             self.page.evaluate(js_script, {"targetSelector": target_selector})
414 |         except Exception as e:
415 |             logger.error(f"Failed to show assertion type panel: {e}", exc_info=True)
416 | 
417 |     def show_assertion_params_panel(self, target_selector: str, assertion_type: str, param_labels: List[str]):
418 |         """Shows the panel for entering assertion parameters."""
419 |         if self.headless or not self.page: return
420 |         try:
421 |             js_script = f"""
422 |             (args) => {{
423 |                 ({RECORDER_PANEL_JS})(); // Ensure functions are defined
424 |                 if (window._recorder_showAssertionParamsPanel) {{
425 |                     window._recorder_showAssertionParamsPanel(args.targetSelector, args.assertionType, args.paramLabels);
426 |                 }} else {{ console.error('Assertion params panel function not defined!'); }}
427 |             }}"""
428 |             self.page.evaluate(js_script, {
429 |                 "targetSelector": target_selector,
430 |                 "assertionType": assertion_type,
431 |                 "paramLabels": param_labels
432 |             })
433 |         except Exception as e:
434 |             logger.error(f"Failed to show assertion params panel: {e}", exc_info=True)
435 | 
436 |     def get_assertion_parameters_from_panel(self, count: int) -> Optional[Dict[str, str]]:
437 |         """Retrieves the parameter values entered in the assertion panel."""
438 |         if self.headless or not self.page: return None
439 |         try:
440 |             params = self.page.evaluate("window._recorder_getAssertionParams ? window._recorder_getAssertionParams(count) : null", {"count": count})
441 |             return params
442 |         except Exception as e:
443 |             logger.error(f"Failed to get assertion parameters from panel: {e}")
444 |             return None
445 | 
446 |     def show_recorder_panel(self, step_description: str, suggestion_text: str):
447 |         """Shows the recorder UI panel with step info."""
448 |         if self.headless or not self.page:
449 |             logger.warning("Cannot show recorder panel (headless or no page).")
450 |             return
451 |         try:
452 |             # Evaluate a script that FIRST defines the functions, THEN calls showPanel
453 |             js_script = f"""
454 |             (args) => {{
455 |                 // Ensure panel functions are defined (runs the definitions)
456 |                 ({RECORDER_PANEL_JS})();
457 | 
458 |                 // Now call the show function
459 |                 if (window._recorder_showPanel) {{
460 |                     window._recorder_showPanel(args.stepDescription, args.suggestionText);
461 |                 }} else {{
462 |                      console.error('[Recorder Panel] _recorder_showPanel function is still not defined after injection attempt!');
463 |                 }}
464 |             }}
465 |             """
466 |             self.page.evaluate(js_script, {"stepDescription": step_description, "suggestionText": suggestion_text})
467 |         except Exception as e:
468 |             logger.error(f"Failed to show recorder panel: {e}", exc_info=True) # Log full trace for debugging
469 | 
470 |     def hide_recorder_panel(self):
471 |         """Hides the recorder UI panel if it exists."""
472 |         if self.headless or not self.page: return
473 |         try:
474 |             # Check if function exists before calling
475 |             self.page.evaluate("if (window._recorder_hidePanel) window._recorder_hidePanel()")
476 |         except Exception as e:
477 |             logger.warning(f"Failed to hide recorder panel (might be removed or page navigated): {e}")
478 | 
479 |     def remove_recorder_panel(self):
480 |         """Removes the recorder UI panel from the DOM if it exists."""
481 |         if self.headless or not self.page: return
482 |         try:
483 |             # Check if function exists before calling
484 |             self.page.evaluate("if (window._recorder_removePanel) window._recorder_removePanel()")
485 |         except Exception as e:
486 |             logger.warning(f"Failed to remove recorder panel (might be removed or page navigated): {e}")
487 | 
488 |     def prompt_parameterization_in_panel(self, default_value: str) -> bool:
489 |         """Shows the parameterization input field, ensuring functions are defined."""
490 |         if self.headless or not self.page: return False
491 |         try:
492 |             # Combine definition and call again
493 |             js_script = f"""
494 |             (args) => {{
495 |                  // Ensure panel functions are defined
496 |                 ({RECORDER_PANEL_JS})();
497 | 
498 |                 // Now call the show param UI function
499 |                 if (window._recorder_showParamUI) {{
500 |                     return window._recorder_showParamUI(args.defaultValue);
501 |                 }} else {{
502 |                      console.error('[Recorder Panel] _recorder_showParamUI function is still not defined!');
503 |                      return false;
504 |                 }}
505 |             }}
506 |             """
507 |             success = self.page.evaluate(js_script, {"defaultValue": default_value})
508 |             return success if success is True else False # Ensure boolean return
509 |         except Exception as e:
510 |             logger.error(f"Failed to show parameterization UI in panel: {e}")
511 |             return False
512 | 
513 |     def wait_for_panel_interaction(self, timeout_seconds: float) -> Optional[str]:
514 |         """
515 |         Waits for the user to click a button on the recorder panel.
516 |         Returns the choice ('accept', 'skip', 'abort', 'parameterized') or None on timeout.
517 |         """
518 |         if self.headless or not self.page or not self._recorder_ui_injected: return None
519 | 
520 |         with self._panel_interaction_lock: # Prevent concurrent waits if called rapidly
521 |             js_condition = "() => window._recorder_user_choice !== undefined"
522 |             timeout_ms = timeout_seconds * 1000
523 |             user_choice = None
524 | 
525 |             logger.info(f"Waiting up to {timeout_seconds}s for user interaction via UI panel...")
526 | 
527 |             try:
528 |                 # Ensure the flag is initially undefined before waiting
529 |                 self.page.evaluate("window._recorder_user_choice = undefined")
530 | 
531 |                 self.page.wait_for_function(js_condition, timeout=timeout_ms)
532 | 
533 |                 # If wait succeeds, get the choice
534 |                 user_choice = self.page.evaluate("window._recorder_user_choice")
535 |                 logger.info(f"User interaction detected via panel: '{user_choice}'")
536 | 
537 |             except PlaywrightTimeoutError:
538 |                 logger.warning("Timeout reached waiting for panel interaction.")
539 |                 user_choice = None # Timeout occurred
540 |             except Exception as e:
541 |                 logger.error(f"Error during page.wait_for_function for panel interaction: {e}", exc_info=True)
542 |                 user_choice = None # Treat other errors as timeout/failure
543 |             finally:
544 |                 # Reset the flag *immediately after reading or timeout* for the next wait
545 |                  try:
546 |                      self.page.evaluate("window._recorder_user_choice = undefined")
547 |                  except Exception:
548 |                       logger.warning("Could not reset panel choice flag after interaction/timeout.")
549 | 
550 |         return user_choice
551 | 
552 |     def get_parameterization_result(self) -> Optional[str]:
553 |          """Retrieves the parameter name entered in the panel. Call after wait_for_panel_interaction returns 'parameterized'."""
554 |          if self.headless or not self.page or not self._recorder_ui_injected: return None
555 |          try:
556 |              param_name = self.page.evaluate("window._recorder_parameter_name")
557 |              # Reset the flag after reading
558 |              self.page.evaluate("window._recorder_parameter_name = undefined")
559 |              logger.debug(f"Retrieved parameter name from panel: {param_name}")
560 |              return param_name # Can be string or null
561 |          except Exception as e:
562 |              logger.error(f"Failed to get parameter name from panel: {e}")
563 |              return None
564 | 
565 | 
566 |     
```

--------------------------------------------------------------------------------
/src/dom/buildDomTree.js:
--------------------------------------------------------------------------------

```javascript
   1 | (
   2 |   args = {
   3 |     doHighlightElements: true,
   4 |     focusHighlightIndex: -1,
   5 |     viewportExpansion: 0,
   6 |     debugMode: false,
   7 |   }
   8 | ) => {
   9 |   const { doHighlightElements, focusHighlightIndex, viewportExpansion, debugMode } = args;
  10 |   let highlightIndex = 0; // Reset highlight index
  11 | 
  12 |   // Add timing stack to handle recursion
  13 |   const TIMING_STACK = {
  14 |     nodeProcessing: [],
  15 |     treeTraversal: [],
  16 |     highlighting: [],
  17 |     current: null
  18 |   };
  19 | 
  20 |   function pushTiming(type) {
  21 |     TIMING_STACK[type] = TIMING_STACK[type] || [];
  22 |     TIMING_STACK[type].push(performance.now());
  23 |   }
  24 | 
  25 |   function popTiming(type) {
  26 |     const start = TIMING_STACK[type].pop();
  27 |     const duration = performance.now() - start;
  28 |     return duration;
  29 |   }
  30 | 
  31 |   // Only initialize performance tracking if in debug mode
  32 |   const PERF_METRICS = debugMode ? {
  33 |     buildDomTreeCalls: 0,
  34 |     timings: {
  35 |       buildDomTree: 0,
  36 |       highlightElement: 0,
  37 |       isInteractiveElement: 0,
  38 |       isElementVisible: 0,
  39 |       isTopElement: 0,
  40 |       isInExpandedViewport: 0,
  41 |       isTextNodeVisible: 0,
  42 |       getEffectiveScroll: 0,
  43 |     },
  44 |     cacheMetrics: {
  45 |       boundingRectCacheHits: 0,
  46 |       boundingRectCacheMisses: 0,
  47 |       computedStyleCacheHits: 0,
  48 |       computedStyleCacheMisses: 0,
  49 |       getBoundingClientRectTime: 0,
  50 |       getComputedStyleTime: 0,
  51 |       boundingRectHitRate: 0,
  52 |       computedStyleHitRate: 0,
  53 |       overallHitRate: 0,
  54 |     },
  55 |     nodeMetrics: {
  56 |       totalNodes: 0,
  57 |       processedNodes: 0,
  58 |       skippedNodes: 0,
  59 |     },
  60 |     buildDomTreeBreakdown: {
  61 |       totalTime: 0,
  62 |       totalSelfTime: 0,
  63 |       buildDomTreeCalls: 0,
  64 |       domOperations: {
  65 |         getBoundingClientRect: 0,
  66 |         getComputedStyle: 0,
  67 |       },
  68 |       domOperationCounts: {
  69 |         getBoundingClientRect: 0,
  70 |         getComputedStyle: 0,
  71 |       }
  72 |     }
  73 |   } : null;
  74 | 
  75 |   // Simple timing helper that only runs in debug mode
  76 |   function measureTime(fn) {
  77 |     if (!debugMode) return fn;
  78 |     return function (...args) {
  79 |       const start = performance.now();
  80 |       const result = fn.apply(this, args);
  81 |       const duration = performance.now() - start;
  82 |       return result;
  83 |     };
  84 |   }
  85 | 
  86 |   // Helper to measure DOM operations
  87 |   function measureDomOperation(operation, name) {
  88 |     if (!debugMode) return operation();
  89 | 
  90 |     const start = performance.now();
  91 |     const result = operation();
  92 |     const duration = performance.now() - start;
  93 | 
  94 |     if (PERF_METRICS && name in PERF_METRICS.buildDomTreeBreakdown.domOperations) {
  95 |       PERF_METRICS.buildDomTreeBreakdown.domOperations[name] += duration;
  96 |       PERF_METRICS.buildDomTreeBreakdown.domOperationCounts[name]++;
  97 |     }
  98 | 
  99 |     return result;
 100 |   }
 101 | 
 102 |   // Add caching mechanisms at the top level
 103 |   const DOM_CACHE = {
 104 |     boundingRects: new WeakMap(),
 105 |     computedStyles: new WeakMap(),
 106 |     clearCache: () => {
 107 |       DOM_CACHE.boundingRects = new WeakMap();
 108 |       DOM_CACHE.computedStyles = new WeakMap();
 109 |     }
 110 |   };
 111 | 
 112 |   // Cache helper functions
 113 |   function getCachedBoundingRect(element) {
 114 |     if (!element) return null;
 115 | 
 116 |     if (DOM_CACHE.boundingRects.has(element)) {
 117 |       if (debugMode && PERF_METRICS) {
 118 |         PERF_METRICS.cacheMetrics.boundingRectCacheHits++;
 119 |       }
 120 |       return DOM_CACHE.boundingRects.get(element);
 121 |     }
 122 | 
 123 |     if (debugMode && PERF_METRICS) {
 124 |       PERF_METRICS.cacheMetrics.boundingRectCacheMisses++;
 125 |     }
 126 | 
 127 |     let rect;
 128 |     if (debugMode) {
 129 |       const start = performance.now();
 130 |       rect = element.getBoundingClientRect();
 131 |       const duration = performance.now() - start;
 132 |       if (PERF_METRICS) {
 133 |         PERF_METRICS.buildDomTreeBreakdown.domOperations.getBoundingClientRect += duration;
 134 |         PERF_METRICS.buildDomTreeBreakdown.domOperationCounts.getBoundingClientRect++;
 135 |       }
 136 |     } else {
 137 |       rect = element.getBoundingClientRect();
 138 |     }
 139 | 
 140 |     if (rect) {
 141 |       DOM_CACHE.boundingRects.set(element, rect);
 142 |     }
 143 |     return rect;
 144 |   }
 145 | 
 146 |   function getCachedComputedStyle(element) {
 147 |     if (!element) return null;
 148 | 
 149 |     if (DOM_CACHE.computedStyles.has(element)) {
 150 |       if (debugMode && PERF_METRICS) {
 151 |         PERF_METRICS.cacheMetrics.computedStyleCacheHits++;
 152 |       }
 153 |       return DOM_CACHE.computedStyles.get(element);
 154 |     }
 155 | 
 156 |     if (debugMode && PERF_METRICS) {
 157 |       PERF_METRICS.cacheMetrics.computedStyleCacheMisses++;
 158 |     }
 159 | 
 160 |     let style;
 161 |     if (debugMode) {
 162 |       const start = performance.now();
 163 |       style = window.getComputedStyle(element);
 164 |       const duration = performance.now() - start;
 165 |       if (PERF_METRICS) {
 166 |         PERF_METRICS.buildDomTreeBreakdown.domOperations.getComputedStyle += duration;
 167 |         PERF_METRICS.buildDomTreeBreakdown.domOperationCounts.getComputedStyle++;
 168 |       }
 169 |     } else {
 170 |       style = window.getComputedStyle(element);
 171 |     }
 172 | 
 173 |     if (style) {
 174 |       DOM_CACHE.computedStyles.set(element, style);
 175 |     }
 176 |     return style;
 177 |   }
 178 | 
 179 |   /**
 180 |    * Hash map of DOM nodes indexed by their highlight index.
 181 |    *
 182 |    * @type {Object<string, any>}
 183 |    */
 184 |   const DOM_HASH_MAP = {};
 185 | 
 186 |   const ID = { current: 0 };
 187 | 
 188 |   const HIGHLIGHT_CONTAINER_ID = "playwright-highlight-container";
 189 | 
 190 |   /**
 191 |    * Highlights an element in the DOM and returns the index of the next element.
 192 |    */
 193 |   function highlightElement(element, index, parentIframe = null) {
 194 |     if (!element) return index;
 195 | 
 196 |     // Store overlays and the single label for updating
 197 |     const overlays = [];
 198 |     let label = null;
 199 |     let labelWidth = 20; // Approximate label width
 200 |     let labelHeight = 16; // Approximate label height
 201 | 
 202 |     try {
 203 |       // Create or get highlight container
 204 |       let container = document.getElementById(HIGHLIGHT_CONTAINER_ID);
 205 |       if (!container) {
 206 |         container = document.createElement("div");
 207 |         container.id = HIGHLIGHT_CONTAINER_ID;
 208 |         container.style.position = "fixed";
 209 |         container.style.pointerEvents = "none";
 210 |         container.style.top = "0";
 211 |         container.style.left = "0";
 212 |         container.style.width = "100%";
 213 |         container.style.height = "100%";
 214 |         container.style.zIndex = "2147483647";
 215 |         container.style.backgroundColor = 'transparent';
 216 |         document.body.appendChild(container);
 217 |       }
 218 | 
 219 |       // Get element client rects
 220 |       const rects = element.getClientRects(); // Use getClientRects()
 221 | 
 222 |       if (!rects || rects.length === 0) return index; // Exit if no rects
 223 | 
 224 |       // Generate a color based on the index
 225 |       const colors = [
 226 |         "#FF0000",
 227 |         "#00FF00",
 228 |         "#0000FF",
 229 |         "#FFA500",
 230 |         "#800080",
 231 |         "#008080",
 232 |         "#FF69B4",
 233 |         "#4B0082",
 234 |         "#FF4500",
 235 |         "#2E8B57",
 236 |         "#DC143C",
 237 |         "#4682B4",
 238 |       ];
 239 |       const colorIndex = index % colors.length;
 240 |       const baseColor = colors[colorIndex];
 241 |       const backgroundColor = baseColor + "1A"; // 10% opacity version of the color
 242 | 
 243 |       // Get iframe offset if necessary
 244 |       let iframeOffset = { x: 0, y: 0 };
 245 |       if (parentIframe) {
 246 |         const iframeRect = parentIframe.getBoundingClientRect(); // Keep getBoundingClientRect for iframe offset
 247 |         iframeOffset.x = iframeRect.left;
 248 |         iframeOffset.y = iframeRect.top;
 249 |       }
 250 | 
 251 |       // Create highlight overlays for each client rect
 252 |       for (const rect of rects) {
 253 |         if (rect.width === 0 || rect.height === 0) continue; // Skip empty rects
 254 | 
 255 |         const overlay = document.createElement("div");
 256 |         overlay.style.position = "fixed";
 257 |         overlay.style.border = `2px solid ${baseColor}`;
 258 |         overlay.style.backgroundColor = backgroundColor;
 259 |         overlay.style.pointerEvents = "none";
 260 |         overlay.style.boxSizing = "border-box";
 261 | 
 262 |         const top = rect.top + iframeOffset.y;
 263 |         const left = rect.left + iframeOffset.x;
 264 | 
 265 |         overlay.style.top = `${top}px`;
 266 |         overlay.style.left = `${left}px`;
 267 |         overlay.style.width = `${rect.width}px`;
 268 |         overlay.style.height = `${rect.height}px`;
 269 | 
 270 |         container.appendChild(overlay);
 271 |         overlays.push({ element: overlay, initialRect: rect }); // Store overlay and its rect
 272 |       }
 273 | 
 274 |       // Create and position a single label relative to the first rect
 275 |       const firstRect = rects[0];
 276 |       label = document.createElement("div");
 277 |       label.className = "playwright-highlight-label";
 278 |       label.style.position = "fixed";
 279 |       label.style.background = baseColor;
 280 |       label.style.color = "white";
 281 |       label.style.padding = "1px 4px";
 282 |       label.style.borderRadius = "4px";
 283 |       label.style.fontSize = `${Math.min(12, Math.max(8, firstRect.height / 2))}px`;
 284 |       label.textContent = index;
 285 | 
 286 |       labelWidth = label.offsetWidth > 0 ? label.offsetWidth : labelWidth; // Update actual width if possible
 287 |       labelHeight = label.offsetHeight > 0 ? label.offsetHeight : labelHeight; // Update actual height if possible
 288 | 
 289 |       const firstRectTop = firstRect.top + iframeOffset.y;
 290 |       const firstRectLeft = firstRect.left + iframeOffset.x;
 291 | 
 292 |       let labelTop = firstRectTop + 2;
 293 |       let labelLeft = firstRectLeft + firstRect.width - labelWidth - 2;
 294 | 
 295 |       // Adjust label position if first rect is too small
 296 |       if (firstRect.width < labelWidth + 4 || firstRect.height < labelHeight + 4) {
 297 |         labelTop = firstRectTop - labelHeight - 2;
 298 |         labelLeft = firstRectLeft + firstRect.width - labelWidth; // Align with right edge
 299 |         if (labelLeft < iframeOffset.x) labelLeft = firstRectLeft; // Prevent going off-left
 300 |       }
 301 | 
 302 |       // Ensure label stays within viewport bounds slightly better
 303 |       labelTop = Math.max(0, Math.min(labelTop, window.innerHeight - labelHeight));
 304 |       labelLeft = Math.max(0, Math.min(labelLeft, window.innerWidth - labelWidth));
 305 | 
 306 | 
 307 |       label.style.top = `${labelTop}px`;
 308 |       label.style.left = `${labelLeft}px`;
 309 | 
 310 |       container.appendChild(label);
 311 | 
 312 |       // Update positions on scroll/resize
 313 |       const updatePositions = () => {
 314 |         const newRects = element.getClientRects(); // Get fresh rects
 315 |         let newIframeOffset = { x: 0, y: 0 };
 316 | 
 317 |         if (parentIframe) {
 318 |           const iframeRect = parentIframe.getBoundingClientRect(); // Keep getBoundingClientRect for iframe
 319 |           newIframeOffset.x = iframeRect.left;
 320 |           newIframeOffset.y = iframeRect.top;
 321 |         }
 322 | 
 323 |         // Update each overlay
 324 |         overlays.forEach((overlayData, i) => {
 325 |           if (i < newRects.length) { // Check if rect still exists
 326 |             const newRect = newRects[i];
 327 |             const newTop = newRect.top + newIframeOffset.y;
 328 |             const newLeft = newRect.left + newIframeOffset.x;
 329 | 
 330 |             overlayData.element.style.top = `${newTop}px`;
 331 |             overlayData.element.style.left = `${newLeft}px`;
 332 |             overlayData.element.style.width = `${newRect.width}px`;
 333 |             overlayData.element.style.height = `${newRect.height}px`;
 334 |             overlayData.element.style.display = (newRect.width === 0 || newRect.height === 0) ? 'none' : 'block';
 335 |           } else {
 336 |             // If fewer rects now, hide extra overlays
 337 |             overlayData.element.style.display = 'none';
 338 |           }
 339 |         });
 340 | 
 341 |         // If there are fewer new rects than overlays, hide the extras
 342 |         if (newRects.length < overlays.length) {
 343 |           for (let i = newRects.length; i < overlays.length; i++) {
 344 |             overlays[i].element.style.display = 'none';
 345 |           }
 346 |         }
 347 | 
 348 |         // Update label position based on the first new rect
 349 |         if (label && newRects.length > 0) {
 350 |           const firstNewRect = newRects[0];
 351 |           const firstNewRectTop = firstNewRect.top + newIframeOffset.y;
 352 |           const firstNewRectLeft = firstNewRect.left + newIframeOffset.x;
 353 | 
 354 |           let newLabelTop = firstNewRectTop + 2;
 355 |           let newLabelLeft = firstNewRectLeft + firstNewRect.width - labelWidth - 2;
 356 | 
 357 |           if (firstNewRect.width < labelWidth + 4 || firstNewRect.height < labelHeight + 4) {
 358 |             newLabelTop = firstNewRectTop - labelHeight - 2;
 359 |             newLabelLeft = firstNewRectLeft + firstNewRect.width - labelWidth;
 360 |             if (newLabelLeft < newIframeOffset.x) newLabelLeft = firstNewRectLeft;
 361 |           }
 362 | 
 363 |           // Ensure label stays within viewport bounds
 364 |           newLabelTop = Math.max(0, Math.min(newLabelTop, window.innerHeight - labelHeight));
 365 |           newLabelLeft = Math.max(0, Math.min(newLabelLeft, window.innerWidth - labelWidth));
 366 | 
 367 |           label.style.top = `${newLabelTop}px`;
 368 |           label.style.left = `${newLabelLeft}px`;
 369 |           label.style.display = 'block';
 370 |         } else if (label) {
 371 |           // Hide label if element has no rects anymore
 372 |           label.style.display = 'none';
 373 |         }
 374 |       };
 375 | 
 376 |       window.addEventListener('scroll', updatePositions, true); // Use capture phase
 377 |       window.addEventListener('resize', updatePositions);
 378 | 
 379 |       // TODO: Add cleanup logic to remove listeners and elements when done.
 380 | 
 381 |       return index + 1;
 382 |     } finally {
 383 |       // popTiming('highlighting'); // Assuming this was a typo and should be removed or corrected
 384 |     }
 385 |   }
 386 | 
 387 |   function getElementPosition(currentElement) {
 388 |     if (!currentElement.parentElement) {
 389 |       return 0; // No parent means no siblings
 390 |     }
 391 |   
 392 |     const tagName = currentElement.nodeName.toLowerCase();
 393 |   
 394 |     const siblings = Array.from(currentElement.parentElement.children)
 395 |       .filter((sib) => sib.nodeName.toLowerCase() === tagName);
 396 |   
 397 |     if (siblings.length === 1) {
 398 |       return 0; // Only element of its type
 399 |     }
 400 |   
 401 |     const index = siblings.indexOf(currentElement) + 1; // 1-based index
 402 |     return index;
 403 |   }
 404 | 
 405 |   /**
 406 |    * Returns an XPath tree string for an element.
 407 |    */
 408 |   function getXPathTree(element, stopAtBoundary = true) {
 409 |     const segments = [];
 410 |     let currentElement = element;
 411 | 
 412 |     while (currentElement && currentElement.nodeType === Node.ELEMENT_NODE) {
 413 |       // Stop if we hit a shadow root or iframe
 414 |       if (
 415 |         stopAtBoundary &&
 416 |         (currentElement.parentNode instanceof ShadowRoot ||
 417 |           currentElement.parentNode instanceof HTMLIFrameElement)
 418 |       ) {
 419 |         break;
 420 |       }
 421 | 
 422 |       const position = getElementPosition(currentElement);
 423 |       const tagName = currentElement.nodeName.toLowerCase();
 424 |       const xpathIndex = position > 0 ? `[${position}]` : "";
 425 |       segments.unshift(`${tagName}${xpathIndex}`);
 426 | 
 427 |       currentElement = currentElement.parentNode;
 428 |     }
 429 | 
 430 |     return segments.join("/");
 431 |   }
 432 | 
 433 |   /**
 434 |    * Checks if a text node is visible.
 435 |    */
 436 |   function isTextNodeVisible(textNode) {
 437 |     try {
 438 |       const range = document.createRange();
 439 |       range.selectNodeContents(textNode);
 440 |       const rects = range.getClientRects(); // Use getClientRects for Range
 441 | 
 442 |       if (!rects || rects.length === 0) {
 443 |         return false;
 444 |       }
 445 | 
 446 |       let isAnyRectVisible = false;
 447 |       let isAnyRectInViewport = false;
 448 | 
 449 |       for (const rect of rects) {
 450 |         // Check size
 451 |         if (rect.width > 0 && rect.height > 0) {
 452 |           isAnyRectVisible = true;
 453 | 
 454 |           // Viewport check for this rect
 455 |           if (!(
 456 |             rect.bottom < -viewportExpansion ||
 457 |             rect.top > window.innerHeight + viewportExpansion ||
 458 |             rect.right < -viewportExpansion ||
 459 |             rect.left > window.innerWidth + viewportExpansion
 460 |           ) || viewportExpansion === -1) {
 461 |             isAnyRectInViewport = true;
 462 |             break; // Found a visible rect in viewport, no need to check others
 463 |           }
 464 |         }
 465 |       }
 466 | 
 467 |       if (!isAnyRectVisible || !isAnyRectInViewport) {
 468 |         return false;
 469 |       }
 470 | 
 471 |       // Check parent visibility
 472 |       const parentElement = textNode.parentElement;
 473 |       if (!parentElement) return false;
 474 | 
 475 |       try {
 476 |         return isAnyRectInViewport && parentElement.checkVisibility({
 477 |           checkOpacity: true,
 478 |           checkVisibilityCSS: true,
 479 |         });
 480 |       } catch (e) {
 481 |         // Fallback if checkVisibility is not supported
 482 |         const style = window.getComputedStyle(parentElement);
 483 |         return isAnyRectInViewport &&
 484 |           style.display !== 'none' &&
 485 |           style.visibility !== 'hidden' &&
 486 |           style.opacity !== '0';
 487 |       }
 488 |     } catch (e) {
 489 |       console.warn('Error checking text node visibility:', e);
 490 |       return false;
 491 |     }
 492 |   }
 493 | 
 494 |   // Helper function to check if element is accepted
 495 |   function isElementAccepted(element) {
 496 |     if (!element || !element.tagName) return false;
 497 | 
 498 |     // Always accept body and common container elements
 499 |     const alwaysAccept = new Set([
 500 |       "body", "div", "main", "article", "section", "nav", "header", "footer"
 501 |     ]);
 502 |     const tagName = element.tagName.toLowerCase();
 503 | 
 504 |     if (alwaysAccept.has(tagName)) return true;
 505 | 
 506 |     const leafElementDenyList = new Set([
 507 |       "svg",
 508 |       "script",
 509 |       "style",
 510 |       "link",
 511 |       "meta",
 512 |       "noscript",
 513 |       "template",
 514 |     ]);
 515 | 
 516 |     return !leafElementDenyList.has(tagName);
 517 |   }
 518 | 
 519 |   /**
 520 |    * Checks if an element is visible.
 521 |    */
 522 |   function isElementVisible(element) {
 523 |     const style = getCachedComputedStyle(element);
 524 |     return (
 525 |       element.offsetWidth > 0 &&
 526 |       element.offsetHeight > 0 &&
 527 |       style.visibility !== "hidden" &&
 528 |       style.display !== "none"
 529 |     );
 530 |   }
 531 | 
 532 |   /**
 533 |    * Checks if an element is interactive.
 534 |    * 
 535 |    * lots of comments, and uncommented code - to show the logic of what we already tried
 536 |    * 
 537 |    * One of the things we tried at the beginning was also to use event listeners, and other fancy class, style stuff -> what actually worked best was just combining most things with computed cursor style :)
 538 |    */
 539 |   function isInteractiveElement(element) {
 540 |     if (!element || element.nodeType !== Node.ELEMENT_NODE) {
 541 |       return false;
 542 |     }
 543 | 
 544 |     // Define interactive cursors
 545 |     const interactiveCursors = new Set([
 546 |       'pointer',    // Link/clickable elements
 547 |       'move',       // Movable elements
 548 |       'text',       // Text selection
 549 |       'grab',       // Grabbable elements
 550 |       'grabbing',   // Currently grabbing
 551 |       'cell',       // Table cell selection
 552 |       'copy',       // Copy operation
 553 |       'alias',      // Alias creation
 554 |       'all-scroll', // Scrollable content
 555 |       'col-resize', // Column resize
 556 |       'context-menu', // Context menu available
 557 |       'crosshair',  // Precise selection
 558 |       'e-resize',   // East resize
 559 |       'ew-resize',  // East-west resize
 560 |       'help',       // Help available
 561 |       'n-resize',   // North resize
 562 |       'ne-resize',  // Northeast resize
 563 |       'nesw-resize', // Northeast-southwest resize
 564 |       'ns-resize',  // North-south resize
 565 |       'nw-resize',  // Northwest resize
 566 |       'nwse-resize', // Northwest-southeast resize
 567 |       'row-resize', // Row resize
 568 |       's-resize',   // South resize
 569 |       'se-resize',  // Southeast resize
 570 |       'sw-resize',  // Southwest resize
 571 |       'vertical-text', // Vertical text selection
 572 |       'w-resize',   // West resize
 573 |       'zoom-in',    // Zoom in
 574 |       'zoom-out'    // Zoom out
 575 |     ]);
 576 | 
 577 |     // Define non-interactive cursors
 578 |     const nonInteractiveCursors = new Set([
 579 |       'not-allowed', // Action not allowed
 580 |       'no-drop',     // Drop not allowed
 581 |       'wait',        // Processing
 582 |       'progress',    // In progress
 583 |       'initial',     // Initial value
 584 |       'inherit'      // Inherited value
 585 |       //? Let's just include all potentially clickable elements that are not specifically blocked
 586 |       // 'none',        // No cursor
 587 |       // 'default',     // Default cursor 
 588 |       // 'auto',        // Browser default
 589 |     ]);
 590 | 
 591 |     function doesElementHaveInteractivePointer(element) {
 592 |       if (element.tagName.toLowerCase() === "html") return false;
 593 |       const style = getCachedComputedStyle(element);
 594 | 
 595 |       if (interactiveCursors.has(style.cursor)) return true;
 596 | 
 597 |       return false;
 598 |     }
 599 | 
 600 |     let isInteractiveCursor = doesElementHaveInteractivePointer(element);
 601 | 
 602 |     // Genius fix for almost all interactive elements
 603 |     if (isInteractiveCursor) {
 604 |       return true;
 605 |     }
 606 | 
 607 |     const interactiveElements = new Set([
 608 |       "a",          // Links
 609 |       "button",     // Buttons
 610 |       "input",      // All input types (text, checkbox, radio, etc.)
 611 |       "select",     // Dropdown menus
 612 |       "textarea",   // Text areas
 613 |       "details",    // Expandable details
 614 |       "summary",    // Summary element (clickable part of details)
 615 |       "label",      // Form labels (often clickable)
 616 |       "option",     // Select options
 617 |       "optgroup",   // Option groups
 618 |       "fieldset",   // Form fieldsets (can be interactive with legend)
 619 |       "legend",     // Fieldset legends
 620 |     ]);
 621 | 
 622 |     // Define explicit disable attributes and properties
 623 |     const explicitDisableTags = new Set([
 624 |       'disabled',           // Standard disabled attribute
 625 |       // 'aria-disabled',      // ARIA disabled state
 626 |       'readonly',          // Read-only state
 627 |       // 'aria-readonly',     // ARIA read-only state
 628 |       // 'aria-hidden',       // Hidden from accessibility
 629 |       // 'hidden',            // Hidden attribute
 630 |       // 'inert',             // Inert attribute
 631 |       // 'aria-inert',        // ARIA inert state
 632 |       // 'tabindex="-1"',     // Removed from tab order
 633 |       // 'aria-hidden="true"' // Hidden from screen readers
 634 |     ]);
 635 | 
 636 |     // handle inputs, select, checkbox, radio, textarea, button and make sure they are not cursor style disabled/not-allowed
 637 |     if (interactiveElements.has(element.tagName.toLowerCase())) {
 638 |       const style = getCachedComputedStyle(element);
 639 | 
 640 |       // Check for non-interactive cursor
 641 |       if (nonInteractiveCursors.has(style.cursor)) {
 642 |         return false;
 643 |       }
 644 | 
 645 |       // Check for explicit disable attributes
 646 |       for (const disableTag of explicitDisableTags) {
 647 |         if (element.hasAttribute(disableTag) ||
 648 |           element.getAttribute(disableTag) === 'true' ||
 649 |           element.getAttribute(disableTag) === '') {
 650 |           return false;
 651 |         }
 652 |       }
 653 | 
 654 |       // Check for disabled property on form elements
 655 |       if (element.disabled) {
 656 |         return false;
 657 |       }
 658 | 
 659 |       // Check for readonly property on form elements
 660 |       if (element.readOnly) {
 661 |         return false;
 662 |       }
 663 | 
 664 |       // Check for inert property
 665 |       if (element.inert) {
 666 |         return false;
 667 |       }
 668 | 
 669 |       return true;
 670 |     }
 671 | 
 672 |     const tagName = element.tagName.toLowerCase();
 673 |     const role = element.getAttribute("role");
 674 |     const ariaRole = element.getAttribute("aria-role");
 675 | 
 676 |     // Added enhancement to capture dropdown interactive elements
 677 |     if (element.classList && (
 678 |       element.classList.contains("button") ||
 679 |       element.classList.contains('dropdown-toggle') ||
 680 |       element.getAttribute('data-index') ||
 681 |       element.getAttribute('data-toggle') === 'dropdown' ||
 682 |       element.getAttribute('aria-haspopup') === 'true'
 683 |     )) {
 684 |       return true;
 685 |     }
 686 | 
 687 |     const interactiveRoles = new Set([
 688 |       'button',           // Directly clickable element
 689 |       // 'link',            // Clickable link
 690 |       // 'menuitem',        // Clickable menu item
 691 |       'menuitemradio',   // Radio-style menu item (selectable)
 692 |       'menuitemcheckbox', // Checkbox-style menu item (toggleable)
 693 |       'radio',           // Radio button (selectable)
 694 |       'checkbox',        // Checkbox (toggleable)
 695 |       'tab',             // Tab (clickable to switch content)
 696 |       'switch',          // Toggle switch (clickable to change state)
 697 |       'slider',          // Slider control (draggable)
 698 |       'spinbutton',      // Number input with up/down controls
 699 |       'combobox',        // Dropdown with text input
 700 |       'searchbox',       // Search input field
 701 |       'textbox',         // Text input field
 702 |       // 'listbox',         // Selectable list
 703 |       'option',          // Selectable option in a list
 704 |       'scrollbar'        // Scrollable control
 705 |     ]);
 706 | 
 707 |     // Basic role/attribute checks
 708 |     const hasInteractiveRole =
 709 |       interactiveElements.has(tagName) ||
 710 |       interactiveRoles.has(role) ||
 711 |       interactiveRoles.has(ariaRole);
 712 | 
 713 |     if (hasInteractiveRole) return true;
 714 | 
 715 |     // check whether element has event listeners
 716 |     try {
 717 |       if (typeof getEventListeners === 'function') {
 718 |         const listeners = getEventListeners(element);
 719 |         const mouseEvents = ['click', 'mousedown', 'mouseup', 'dblclick'];
 720 |         for (const eventType of mouseEvents) {
 721 |           if (listeners[eventType] && listeners[eventType].length > 0) {
 722 |             return true; // Found a mouse interaction listener
 723 |           }
 724 |         }
 725 |       } else {
 726 |         // Fallback: Check common event attributes if getEventListeners is not available
 727 |         const commonMouseAttrs = ['onclick', 'onmousedown', 'onmouseup', 'ondblclick'];
 728 |         if (commonMouseAttrs.some(attr => element.hasAttribute(attr))) {
 729 |           return true;
 730 |         }
 731 |       }
 732 |     } catch (e) {
 733 |       // console.warn(`Could not check event listeners for ${element.tagName}:`, e);
 734 |       // If checking listeners fails, rely on other checks
 735 |     }
 736 | 
 737 |     return false
 738 |   }
 739 | 
 740 | 
 741 |   /**
 742 |    * Checks if an element is the topmost element at its position.
 743 |    */
 744 |   function isTopElement(element) {
 745 |     const rects = element.getClientRects(); // Use getClientRects
 746 | 
 747 |     if (!rects || rects.length === 0) {
 748 |       return false; // No geometry, cannot be top
 749 |     }
 750 | 
 751 |     let isAnyRectInViewport = false;
 752 |     for (const rect of rects) {
 753 |       // Use the same logic as isInExpandedViewport check
 754 |       if (rect.width > 0 && rect.height > 0 && !( // Only check non-empty rects
 755 |         rect.bottom < -viewportExpansion ||
 756 |         rect.top > window.innerHeight + viewportExpansion ||
 757 |         rect.right < -viewportExpansion ||
 758 |         rect.left > window.innerWidth + viewportExpansion
 759 |       ) || viewportExpansion === -1) {
 760 |         isAnyRectInViewport = true;
 761 |         break;
 762 |       }
 763 |     }
 764 | 
 765 |     if (!isAnyRectInViewport) {
 766 |       return false; // All rects are outside the viewport area
 767 |     }
 768 | 
 769 | 
 770 |     // Find the correct document context and root element
 771 |     let doc = element.ownerDocument;
 772 | 
 773 |     // If we're in an iframe, elements are considered top by default
 774 |     if (doc !== window.document) {
 775 |       return true;
 776 |     }
 777 | 
 778 |     // For shadow DOM, we need to check within its own root context
 779 |     const shadowRoot = element.getRootNode();
 780 |     if (shadowRoot instanceof ShadowRoot) {
 781 |       const centerX = rects[Math.floor(rects.length / 2)].left + rects[Math.floor(rects.length / 2)].width / 2;
 782 |       const centerY = rects[Math.floor(rects.length / 2)].top + rects[Math.floor(rects.length / 2)].height / 2;
 783 | 
 784 |       try {
 785 |         const topEl = measureDomOperation(
 786 |           () => shadowRoot.elementFromPoint(centerX, centerY),
 787 |           'elementFromPoint'
 788 |         );
 789 |         if (!topEl) return false;
 790 | 
 791 |         let current = topEl;
 792 |         while (current && current !== shadowRoot) {
 793 |           if (current === element) return true;
 794 |           current = current.parentElement;
 795 |         }
 796 |         return false;
 797 |       } catch (e) {
 798 |         return true;
 799 |       }
 800 |     }
 801 | 
 802 |     // For elements in viewport, check if they're topmost
 803 |     const centerX = rects[Math.floor(rects.length / 2)].left + rects[Math.floor(rects.length / 2)].width / 2;
 804 |     const centerY = rects[Math.floor(rects.length / 2)].top + rects[Math.floor(rects.length / 2)].height / 2;
 805 | 
 806 |     try {
 807 |       const topEl = document.elementFromPoint(centerX, centerY);
 808 |       if (!topEl) return false;
 809 | 
 810 |       let current = topEl;
 811 |       while (current && current !== document.documentElement) {
 812 |         if (current === element) return true;
 813 |         current = current.parentElement;
 814 |       }
 815 |       return false;
 816 |     } catch (e) {
 817 |       return true;
 818 |     }
 819 |   }
 820 | 
 821 |   /**
 822 |    * Checks if an element is within the expanded viewport.
 823 |    */
 824 |   function isInExpandedViewport(element, viewportExpansion) {
 825 |     return true
 826 | 
 827 |     if (viewportExpansion === -1) {
 828 |       return true;
 829 |     }
 830 | 
 831 |     const rects = element.getClientRects(); // Use getClientRects
 832 | 
 833 |     if (!rects || rects.length === 0) {
 834 |       // Fallback to getBoundingClientRect if getClientRects is empty,
 835 |       // useful for elements like <svg> that might not have client rects but have a bounding box.
 836 |       const boundingRect = getCachedBoundingRect(element);
 837 |       if (!boundingRect || boundingRect.width === 0 || boundingRect.height === 0) {
 838 |         return false;
 839 |       }
 840 |       return !(
 841 |         boundingRect.bottom < -viewportExpansion ||
 842 |         boundingRect.top > window.innerHeight + viewportExpansion ||
 843 |         boundingRect.right < -viewportExpansion ||
 844 |         boundingRect.left > window.innerWidth + viewportExpansion
 845 |       );
 846 |     }
 847 | 
 848 | 
 849 |     // Check if *any* client rect is within the viewport
 850 |     for (const rect of rects) {
 851 |       if (rect.width === 0 || rect.height === 0) continue; // Skip empty rects
 852 | 
 853 |       if (!(
 854 |         rect.bottom < -viewportExpansion ||
 855 |         rect.top > window.innerHeight + viewportExpansion ||
 856 |         rect.right < -viewportExpansion ||
 857 |         rect.left > window.innerWidth + viewportExpansion
 858 |       )) {
 859 |         return true; // Found at least one rect in the viewport
 860 |       }
 861 |     }
 862 | 
 863 |     return false; // No rects were found in the viewport
 864 |   }
 865 | 
 866 |   // Add this new helper function
 867 |   function getEffectiveScroll(element) {
 868 |     let currentEl = element;
 869 |     let scrollX = 0;
 870 |     let scrollY = 0;
 871 | 
 872 |     return measureDomOperation(() => {
 873 |       while (currentEl && currentEl !== document.documentElement) {
 874 |         if (currentEl.scrollLeft || currentEl.scrollTop) {
 875 |           scrollX += currentEl.scrollLeft;
 876 |           scrollY += currentEl.scrollTop;
 877 |         }
 878 |         currentEl = currentEl.parentElement;
 879 |       }
 880 | 
 881 |       scrollX += window.scrollX;
 882 |       scrollY += window.scrollY;
 883 | 
 884 |       return { scrollX, scrollY };
 885 |     }, 'scrollOperations');
 886 |   }
 887 | 
 888 |   // Add these helper functions at the top level
 889 |   function isInteractiveCandidate(element) {
 890 |     if (!element || element.nodeType !== Node.ELEMENT_NODE) return false;
 891 | 
 892 |     const tagName = element.tagName.toLowerCase();
 893 | 
 894 |     // Fast-path for common interactive elements
 895 |     const interactiveElements = new Set([
 896 |       "a", "button", "input", "select", "textarea", "details", "summary"
 897 |     ]);
 898 | 
 899 |     if (interactiveElements.has(tagName)) return true;
 900 | 
 901 |     // Quick attribute checks without getting full lists
 902 |     const hasQuickInteractiveAttr = element.hasAttribute("onclick") ||
 903 |       element.hasAttribute("role") ||
 904 |       element.hasAttribute("tabindex") ||
 905 |       element.hasAttribute("aria-") ||
 906 |       element.hasAttribute("data-action") ||
 907 |       element.getAttribute("contenteditable") == "true";
 908 | 
 909 |     return hasQuickInteractiveAttr;
 910 |   }
 911 | 
 912 |   // --- Define constants for distinct interaction check ---
 913 |   const DISTINCT_INTERACTIVE_TAGS = new Set([
 914 |     'a', 'button', 'input', 'select', 'textarea', 'summary', 'details', 'label', 'option'
 915 |   ]);
 916 |   const INTERACTIVE_ROLES = new Set([
 917 |     'button', 'link', 'menuitem', 'menuitemradio', 'menuitemcheckbox',
 918 |     'radio', 'checkbox', 'tab', 'switch', 'slider', 'spinbutton',
 919 |     'combobox', 'searchbox', 'textbox', 'listbox', 'option', 'scrollbar'
 920 |   ]);
 921 | 
 922 |   /**
 923 |    * Checks if an element likely represents a distinct interaction
 924 |    * separate from its parent (if the parent is also interactive).
 925 |    */
 926 |   function isElementDistinctInteraction(element) {
 927 |     if (!element || element.nodeType !== Node.ELEMENT_NODE) {
 928 |       return false;
 929 |     }
 930 | 
 931 | 
 932 |     const tagName = element.tagName.toLowerCase();
 933 |     const role = element.getAttribute('role');
 934 | 
 935 |     // Check if it's an iframe - always distinct boundary
 936 |     if (tagName === 'iframe') {
 937 |       return true;
 938 |     }
 939 | 
 940 |     // Check tag name
 941 |     if (DISTINCT_INTERACTIVE_TAGS.has(tagName)) {
 942 |       return true;
 943 |     }
 944 |     // Check interactive roles
 945 |     if (role && INTERACTIVE_ROLES.has(role)) {
 946 |       return true;
 947 |     }
 948 |     // Check contenteditable
 949 |     if (element.isContentEditable || element.getAttribute('contenteditable') === 'true') {
 950 |       return true;
 951 |     }
 952 |     // Check for common testing/automation attributes
 953 |     if (element.hasAttribute('data-testid') || element.hasAttribute('data-cy') || element.hasAttribute('data-test')) {
 954 |       return true;
 955 |     }
 956 |     // Check for explicit onclick handler (attribute or property)
 957 |     if (element.hasAttribute('onclick') || typeof element.onclick === 'function') {
 958 |       return true;
 959 |     }
 960 |     // Check for other common interaction event listeners
 961 |     try {
 962 |       if (typeof getEventListeners === 'function') {
 963 |         const listeners = getEventListeners(element);
 964 |         const interactionEvents = ['mousedown', 'mouseup', 'keydown', 'keyup', 'submit', 'change', 'input', 'focus', 'blur'];
 965 |         for (const eventType of interactionEvents) {
 966 |           if (listeners[eventType] && listeners[eventType].length > 0) {
 967 |             return true; // Found a common interaction listener
 968 |           }
 969 |         }
 970 |       } else {
 971 |         // Fallback: Check common event attributes if getEventListeners is not available
 972 |         const commonEventAttrs = ['onmousedown', 'onmouseup', 'onkeydown', 'onkeyup', 'onsubmit', 'onchange', 'oninput', 'onfocus', 'onblur'];
 973 |         if (commonEventAttrs.some(attr => element.hasAttribute(attr))) {
 974 |           return true;
 975 |         }
 976 |       }
 977 |     } catch (e) {
 978 |       // console.warn(`Could not check event listeners for ${element.tagName}:`, e);
 979 |       // If checking listeners fails, rely on other checks
 980 |     }
 981 | 
 982 | 
 983 |     // Default to false: if it's interactive but doesn't match above,
 984 |     // assume it triggers the same action as the parent.
 985 |     return false;
 986 |   }
 987 |   // --- End distinct interaction check ---
 988 | 
 989 |   /**
 990 |    * Handles the logic for deciding whether to highlight an element and performing the highlight.
 991 |    */
 992 |   function handleHighlighting(nodeData, node, parentIframe, isParentHighlighted) {
 993 |     if (!nodeData.isInteractive) return false; // Not interactive, definitely don't highlight
 994 | 
 995 |     let shouldHighlight = false;
 996 |     if (!isParentHighlighted) {
 997 |       // Parent wasn't highlighted, this interactive node can be highlighted.
 998 |       shouldHighlight = true;
 999 |     } else {
1000 |       // Parent *was* highlighted. Only highlight this node if it represents a distinct interaction.
1001 |       if (isElementDistinctInteraction(node)) {
1002 |         shouldHighlight = true;
1003 |       } else {
1004 |         // console.log(`Skipping highlight for ${nodeData.tagName} (parent highlighted)`);
1005 |         shouldHighlight = false;
1006 |       }
1007 |     }
1008 | 
1009 |     if (shouldHighlight) {
1010 |       // Check viewport status before assigning index and highlighting
1011 |       nodeData.isInViewport = isInExpandedViewport(node, viewportExpansion);
1012 |       if (nodeData.isInViewport) {
1013 |         nodeData.highlightIndex = highlightIndex++;
1014 | 
1015 |         if (doHighlightElements) {
1016 |           if (focusHighlightIndex >= 0) {
1017 |             if (focusHighlightIndex === nodeData.highlightIndex) {
1018 |               highlightElement(node, nodeData.highlightIndex, parentIframe);
1019 |             }
1020 |           } else {
1021 |             highlightElement(node, nodeData.highlightIndex, parentIframe);
1022 |           }
1023 |           return true; // Successfully highlighted
1024 |         }
1025 |       } else {
1026 |         // console.log(`Skipping highlight for ${nodeData.tagName} (outside viewport)`);
1027 |       }
1028 |     }
1029 | 
1030 |     return false; // Did not highlight
1031 |   }
1032 | 
1033 |   /**
1034 |    * Creates a node data object for a given node and its descendants.
1035 |    */
1036 |   function buildDomTree(node, parentIframe = null, isParentHighlighted = false) {
1037 |     if (debugMode) PERF_METRICS.nodeMetrics.totalNodes++;
1038 | 
1039 |     if (!node || node.id === HIGHLIGHT_CONTAINER_ID) {
1040 |       if (debugMode) PERF_METRICS.nodeMetrics.skippedNodes++;
1041 |       return null;
1042 |     }
1043 | 
1044 |     // Special handling for root node (body)
1045 |     if (node === document.body) {
1046 |       const nodeData = {
1047 |         tagName: 'body',
1048 |         attributes: {},
1049 |         xpath: '/body',
1050 |         children: [],
1051 |       };
1052 | 
1053 |       // Process children of body
1054 |       for (const child of node.childNodes) {
1055 |         const domElement = buildDomTree(child, parentIframe, false); // Body's children have no highlighted parent initially
1056 |         if (domElement) nodeData.children.push(domElement);
1057 |       }
1058 | 
1059 |       const id = `${ID.current++}`;
1060 |       DOM_HASH_MAP[id] = nodeData;
1061 |       if (debugMode) PERF_METRICS.nodeMetrics.processedNodes++;
1062 |       return id;
1063 |     }
1064 | 
1065 |     // Early bailout for non-element nodes except text
1066 |     if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.TEXT_NODE) {
1067 |       if (debugMode) PERF_METRICS.nodeMetrics.skippedNodes++;
1068 |       return null;
1069 |     }
1070 | 
1071 |     // Process text nodes
1072 |     if (node.nodeType === Node.TEXT_NODE) {
1073 |       const textContent = node.textContent.trim();
1074 |       if (!textContent) {
1075 |         if (debugMode) PERF_METRICS.nodeMetrics.skippedNodes++;
1076 |         return null;
1077 |       }
1078 | 
1079 |       // Only check visibility for text nodes that might be visible
1080 |       const parentElement = node.parentElement;
1081 |       if (!parentElement || parentElement.tagName.toLowerCase() === 'script') {
1082 |         if (debugMode) PERF_METRICS.nodeMetrics.skippedNodes++;
1083 |         return null;
1084 |       }
1085 | 
1086 |       const id = `${ID.current++}`;
1087 |       DOM_HASH_MAP[id] = {
1088 |         type: "TEXT_NODE",
1089 |         text: textContent,
1090 |         isVisible: isTextNodeVisible(node),
1091 |       };
1092 |       if (debugMode) PERF_METRICS.nodeMetrics.processedNodes++;
1093 |       return id;
1094 |     }
1095 | 
1096 |     // Quick checks for element nodes
1097 |     if (node.nodeType === Node.ELEMENT_NODE && !isElementAccepted(node)) {
1098 |       if (debugMode) PERF_METRICS.nodeMetrics.skippedNodes++;
1099 |       return null;
1100 |     }
1101 | 
1102 |     // Early viewport check - only filter out elements clearly outside viewport
1103 |     if (viewportExpansion !== -1) {
1104 |       const rect = getCachedBoundingRect(node); // Keep for initial quick check
1105 |       const style = getCachedComputedStyle(node);
1106 | 
1107 |       // Skip viewport check for fixed/sticky elements as they may appear anywhere
1108 |       const isFixedOrSticky = style && (style.position === 'fixed' || style.position === 'sticky');
1109 | 
1110 |       // Check if element has actual dimensions using offsetWidth/Height (quick check)
1111 |       const hasSize = node.offsetWidth > 0 || node.offsetHeight > 0;
1112 | 
1113 |       // Use getBoundingClientRect for the quick OUTSIDE check.
1114 |       // isInExpandedViewport will do the more accurate check later if needed.
1115 |       if (!rect || (!isFixedOrSticky && !hasSize && (
1116 |         rect.bottom < -viewportExpansion ||
1117 |         rect.top > window.innerHeight + viewportExpansion ||
1118 |         rect.right < -viewportExpansion ||
1119 |         rect.left > window.innerWidth + viewportExpansion
1120 |       ))) {
1121 |         // console.log("Skipping node outside viewport (quick check):", node.tagName, rect);
1122 |         if (debugMode) PERF_METRICS.nodeMetrics.skippedNodes++;
1123 |         return null;
1124 |       }
1125 |     }
1126 | 
1127 |     // Process element node
1128 |     const nodeData = {
1129 |       tagName: node.tagName.toLowerCase(),
1130 |       attributes: {},
1131 |       xpath: getXPathTree(node, true),
1132 |       children: [],
1133 |     };
1134 | 
1135 |     // Get attributes for interactive elements or potential text containers
1136 |     if (isInteractiveCandidate(node) || node.tagName.toLowerCase() === 'iframe' || node.tagName.toLowerCase() === 'body') {
1137 |       const attributeNames = node.getAttributeNames?.() || [];
1138 |       for (const name of attributeNames) {
1139 |         nodeData.attributes[name] = node.getAttribute(name);
1140 |       }
1141 |     }
1142 | 
1143 |     let nodeWasHighlighted = false;
1144 |     // Perform visibility, interactivity, and highlighting checks
1145 |     if (node.nodeType === Node.ELEMENT_NODE) {
1146 |       nodeData.isVisible = isElementVisible(node); // isElementVisible uses offsetWidth/Height, which is fine
1147 |       
1148 |       if (nodeData.isVisible) {
1149 |         nodeData.isTopElement = isTopElement(node);
1150 |         let shouldCheckInteractivity = false;
1151 |         if (viewportExpansion === -1) {
1152 |             // *** CHANGE: If including all, always check interactivity for visible elements ***
1153 |             shouldCheckInteractivity = true;
1154 |         } else {
1155 |             // Original logic: Only check interactivity if it's the top element within the viewport/expansion zone
1156 |             shouldCheckInteractivity = nodeData.isTopElement;
1157 |         }
1158 |         if (shouldCheckInteractivity) {
1159 |           nodeData.isInteractive = isInteractiveElement(node);
1160 |           nodeWasHighlighted = handleHighlighting(nodeData, node, parentIframe, isParentHighlighted);
1161 |         }
1162 |       }
1163 |     }
1164 | 
1165 |     // Process children, with special handling for iframes and rich text editors
1166 |     if (node.tagName) {
1167 |       const tagName = node.tagName.toLowerCase();
1168 | 
1169 |       // Handle iframes
1170 |       if (tagName === "iframe") {
1171 |         try {
1172 |           const iframeDoc = node.contentDocument || node.contentWindow?.document;
1173 |           if (iframeDoc) {
1174 |             for (const child of iframeDoc.childNodes) {
1175 |               const domElement = buildDomTree(child, node, false);
1176 |               if (domElement) nodeData.children.push(domElement);
1177 |             }
1178 |           }
1179 |         } catch (e) {
1180 |           console.warn("Unable to access iframe:", e);
1181 |         }
1182 |       }
1183 |       // Handle rich text editors and contenteditable elements
1184 |       else if (
1185 |         node.isContentEditable ||
1186 |         node.getAttribute("contenteditable") === "true" ||
1187 |         node.id === "tinymce" ||
1188 |         node.classList.contains("mce-content-body") ||
1189 |         (tagName === "body" && node.getAttribute("data-id")?.startsWith("mce_"))
1190 |       ) {
1191 |         // Process all child nodes to capture formatted text
1192 |         for (const child of node.childNodes) {
1193 |           const domElement = buildDomTree(child, parentIframe, nodeWasHighlighted);
1194 |           if (domElement) nodeData.children.push(domElement);
1195 |         }
1196 |       }
1197 |       else {
1198 |         // Handle shadow DOM
1199 |         if (node.shadowRoot) {
1200 |           nodeData.shadowRoot = true;
1201 |           for (const child of node.shadowRoot.childNodes) {
1202 |             const domElement = buildDomTree(child, parentIframe, nodeWasHighlighted);
1203 |             if (domElement) nodeData.children.push(domElement);
1204 |           }
1205 |         }
1206 |         // Handle regular elements
1207 |         for (const child of node.childNodes) {
1208 |           // Pass the highlighted status of the *current* node to its children
1209 |           const passHighlightStatusToChild = nodeWasHighlighted || isParentHighlighted;
1210 |           const domElement = buildDomTree(child, parentIframe, passHighlightStatusToChild);
1211 |           if (domElement) nodeData.children.push(domElement);
1212 |         }
1213 |       }
1214 |     }
1215 | 
1216 |     // Skip empty anchor tags
1217 |     if (nodeData.tagName === 'a' && nodeData.children.length === 0 && !nodeData.attributes.href) {
1218 |       if (debugMode) PERF_METRICS.nodeMetrics.skippedNodes++;
1219 |       return null;
1220 |     }
1221 | 
1222 |     const id = `${ID.current++}`;
1223 |     DOM_HASH_MAP[id] = nodeData;
1224 |     if (debugMode) PERF_METRICS.nodeMetrics.processedNodes++;
1225 |     return id;
1226 |   }
1227 | 
1228 |   // After all functions are defined, wrap them with performance measurement
1229 |   // Remove buildDomTree from here as we measure it separately
1230 |   highlightElement = measureTime(highlightElement);
1231 |   isInteractiveElement = measureTime(isInteractiveElement);
1232 |   isElementVisible = measureTime(isElementVisible);
1233 |   isTopElement = measureTime(isTopElement);
1234 |   isInExpandedViewport = measureTime(isInExpandedViewport);
1235 |   isTextNodeVisible = measureTime(isTextNodeVisible);
1236 |   getEffectiveScroll = measureTime(getEffectiveScroll);
1237 | 
1238 |   const rootId = buildDomTree(document.body);
1239 | 
1240 |   // Clear the cache before starting
1241 |   DOM_CACHE.clearCache();
1242 | 
1243 |   // Only process metrics in debug mode
1244 |   if (debugMode && PERF_METRICS) {
1245 |     // Convert timings to seconds and add useful derived metrics
1246 |     Object.keys(PERF_METRICS.timings).forEach(key => {
1247 |       PERF_METRICS.timings[key] = PERF_METRICS.timings[key] / 1000;
1248 |     });
1249 | 
1250 |     Object.keys(PERF_METRICS.buildDomTreeBreakdown).forEach(key => {
1251 |       if (typeof PERF_METRICS.buildDomTreeBreakdown[key] === 'number') {
1252 |         PERF_METRICS.buildDomTreeBreakdown[key] = PERF_METRICS.buildDomTreeBreakdown[key] / 1000;
1253 |       }
1254 |     });
1255 | 
1256 |     // Add some useful derived metrics
1257 |     if (PERF_METRICS.buildDomTreeBreakdown.buildDomTreeCalls > 0) {
1258 |       PERF_METRICS.buildDomTreeBreakdown.averageTimePerNode =
1259 |         PERF_METRICS.buildDomTreeBreakdown.totalTime / PERF_METRICS.buildDomTreeBreakdown.buildDomTreeCalls;
1260 |     }
1261 | 
1262 |     PERF_METRICS.buildDomTreeBreakdown.timeInChildCalls =
1263 |       PERF_METRICS.buildDomTreeBreakdown.totalTime - PERF_METRICS.buildDomTreeBreakdown.totalSelfTime;
1264 | 
1265 |     // Add average time per operation to the metrics
1266 |     Object.keys(PERF_METRICS.buildDomTreeBreakdown.domOperations).forEach(op => {
1267 |       const time = PERF_METRICS.buildDomTreeBreakdown.domOperations[op];
1268 |       const count = PERF_METRICS.buildDomTreeBreakdown.domOperationCounts[op];
1269 |       if (count > 0) {
1270 |         PERF_METRICS.buildDomTreeBreakdown.domOperations[`${op}Average`] = time / count;
1271 |       }
1272 |     });
1273 | 
1274 |     // Calculate cache hit rates
1275 |     const boundingRectTotal = PERF_METRICS.cacheMetrics.boundingRectCacheHits + PERF_METRICS.cacheMetrics.boundingRectCacheMisses;
1276 |     const computedStyleTotal = PERF_METRICS.cacheMetrics.computedStyleCacheHits + PERF_METRICS.cacheMetrics.computedStyleCacheMisses;
1277 | 
1278 |     if (boundingRectTotal > 0) {
1279 |       PERF_METRICS.cacheMetrics.boundingRectHitRate = PERF_METRICS.cacheMetrics.boundingRectCacheHits / boundingRectTotal;
1280 |     }
1281 | 
1282 |     if (computedStyleTotal > 0) {
1283 |       PERF_METRICS.cacheMetrics.computedStyleHitRate = PERF_METRICS.cacheMetrics.computedStyleCacheHits / computedStyleTotal;
1284 |     }
1285 | 
1286 |     if ((boundingRectTotal + computedStyleTotal) > 0) {
1287 |       PERF_METRICS.cacheMetrics.overallHitRate =
1288 |         (PERF_METRICS.cacheMetrics.boundingRectCacheHits + PERF_METRICS.cacheMetrics.computedStyleCacheHits) /
1289 |         (boundingRectTotal + computedStyleTotal);
1290 |     }
1291 |   }
1292 | 
1293 |   return debugMode ?
1294 |     { rootId, map: DOM_HASH_MAP, perfMetrics: PERF_METRICS } :
1295 |     { rootId, map: DOM_HASH_MAP };
1296 | }
```
Page 2/4FirstPrevNextLast