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 | }
```