viewpoint_core/page/aria_snapshot/
mod.rs

1//! Page-level ARIA accessibility snapshot methods.
2//!
3//! This module provides methods for capturing accessibility snapshots that span
4//! multiple frames, stitching together the accessibility trees from each frame
5//! into a complete representation of the page.
6//!
7//! # Performance
8//!
9//! Snapshot capture is optimized for performance through:
10//! - **Parallel node resolution**: Multiple `DOM.describeNode` CDP calls are executed
11//!   concurrently (up to 50 by default) instead of sequentially
12//! - **Batch array access**: Element object IDs are retrieved in a single CDP call
13//!   using `Runtime.getProperties` instead of N individual calls
14//! - **Parallel frame capture**: Multi-frame snapshots capture all child frames
15//!   concurrently instead of sequentially
16//!
17//! These optimizations can provide 10-20x performance improvement for large DOMs.
18//!
19//! # Configuration
20//!
21//! Use [`SnapshotOptions`] to tune snapshot behavior:
22//!
23//! ```no_run
24//! use viewpoint_core::{Page, SnapshotOptions};
25//!
26//! # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
27//! // Default options (include refs, 50 concurrent CDP calls)
28//! let snapshot = page.aria_snapshot().await?;
29//!
30//! // Skip ref resolution for maximum performance
31//! let options = SnapshotOptions::default().include_refs(false);
32//! let snapshot = page.aria_snapshot_with_options(options).await?;
33//!
34//! // Increase concurrency for fast networks
35//! let options = SnapshotOptions::default().max_concurrency(100);
36//! let snapshot = page.aria_snapshot_with_options(options).await?;
37//! # Ok(())
38//! # }
39//! ```
40//!
41//! # Frame Boundary Handling
42//!
43//! When capturing aria snapshots, iframes are marked as frame boundaries with
44//! `is_frame: true`. The `aria_snapshot_with_frames()` method captures snapshots
45//! from all frames and stitches them together at the iframe boundaries.
46//!
47//! # Cross-Origin Limitations
48//!
49//! Due to browser security restrictions:
50//! - Same-origin iframes: Content is fully captured and stitched
51//! - Cross-origin iframes: Marked as boundaries with `is_frame: true` but content
52//!   may be limited or empty depending on CDP permissions
53//!
54//! # Example
55//!
56//! ```no_run
57//! use viewpoint_core::Page;
58//!
59//! # async fn example(page: Page) -> Result<(), viewpoint_core::CoreError> {
60//! // Capture complete accessibility tree including iframes
61//! let snapshot = page.aria_snapshot_with_frames().await?;
62//! println!("{}", snapshot);
63//!
64//! // The snapshot will include all frame content stitched together
65//! // Iframes are represented with their content inline
66//! # Ok(())
67//! # }
68//! ```
69
70use std::collections::HashMap;
71
72use futures::stream::{FuturesUnordered, StreamExt};
73use tracing::{debug, instrument, trace, warn};
74use viewpoint_cdp::protocol::dom::{BackendNodeId, DescribeNodeParams, DescribeNodeResult};
75use viewpoint_js::js;
76
77use super::locator::aria_js::aria_snapshot_with_refs_js;
78use super::locator::AriaSnapshot;
79use super::ref_resolution::format_ref;
80use super::Page;
81use crate::error::PageError;
82
83/// Default maximum number of concurrent CDP calls for node resolution.
84pub const DEFAULT_MAX_CONCURRENCY: usize = 50;
85
86/// Configuration options for ARIA snapshot capture.
87///
88/// Use this struct to tune snapshot performance and behavior.
89///
90/// # Example
91///
92/// ```no_run
93/// use viewpoint_core::SnapshotOptions;
94///
95/// // Default options
96/// let options = SnapshotOptions::default();
97///
98/// // Skip ref resolution for faster snapshots
99/// let options = SnapshotOptions::default().include_refs(false);
100///
101/// // Increase concurrency for fast networks
102/// let options = SnapshotOptions::default().max_concurrency(100);
103/// ```
104#[derive(Debug, Clone)]
105pub struct SnapshotOptions {
106    /// Maximum number of concurrent CDP calls for node resolution.
107    ///
108    /// Higher values improve performance but may overwhelm slow connections.
109    /// Default: 50
110    max_concurrency: usize,
111
112    /// Whether to include element refs (backendNodeIds) in the snapshot.
113    ///
114    /// Set to `false` to skip ref resolution for maximum performance when
115    /// you only need the accessibility tree structure.
116    /// Default: true
117    include_refs: bool,
118}
119
120impl Default for SnapshotOptions {
121    fn default() -> Self {
122        Self {
123            max_concurrency: DEFAULT_MAX_CONCURRENCY,
124            include_refs: true,
125        }
126    }
127}
128
129impl SnapshotOptions {
130    /// Set the maximum number of concurrent CDP calls for node resolution.
131    ///
132    /// Higher values improve performance but may overwhelm slow connections.
133    /// Default: 50
134    #[must_use]
135    pub fn max_concurrency(mut self, max: usize) -> Self {
136        self.max_concurrency = max;
137        self
138    }
139
140    /// Set whether to include element refs (backendNodeIds) in the snapshot.
141    ///
142    /// Set to `false` to skip ref resolution for maximum performance when
143    /// you only need the accessibility tree structure.
144    /// Default: true
145    #[must_use]
146    pub fn include_refs(mut self, include: bool) -> Self {
147        self.include_refs = include;
148        self
149    }
150
151    /// Get the maximum concurrency setting.
152    pub fn get_max_concurrency(&self) -> usize {
153        self.max_concurrency
154    }
155
156    /// Get whether refs should be included.
157    pub fn get_include_refs(&self) -> bool {
158        self.include_refs
159    }
160}
161
162impl Page {
163    /// Capture an ARIA accessibility snapshot of the entire page including all frames.
164    ///
165    /// This method captures the accessibility tree of the main frame and all child
166    /// frames (iframes), then stitches them together into a single tree. Frame
167    /// boundaries in the main frame snapshot are replaced with the actual content
168    /// from the corresponding frames.
169    ///
170    /// # Performance
171    ///
172    /// Child frame snapshots are captured in parallel for improved performance.
173    /// For pages with many iframes, this can significantly reduce capture time.
174    ///
175    /// # Frame Content Stitching
176    ///
177    /// The method works by:
178    /// 1. Capturing the main frame's aria snapshot (which marks iframes as boundaries)
179    /// 2. Getting the frame tree from CDP
180    /// 3. For each child frame, capturing its aria snapshot (in parallel)
181    /// 4. Stitching child frame content into the parent snapshot at iframe boundaries
182    ///
183    /// # Cross-Origin Frames
184    ///
185    /// For cross-origin frames, CDP may still be able to capture content through
186    /// out-of-process iframe (OOPIF) handling. However, some content may be
187    /// inaccessible due to browser security policies. In such cases, the frame
188    /// boundary will remain with `is_frame: true` but may have limited or no children.
189    ///
190    /// # Example
191    ///
192    /// ```no_run
193    /// use viewpoint_core::Page;
194    ///
195    /// # async fn example(page: Page) -> Result<(), viewpoint_core::CoreError> {
196    /// let snapshot = page.aria_snapshot_with_frames().await?;
197    ///
198    /// // The snapshot YAML output will show frame content inline:
199    /// // - document "Main Page"
200    /// //   - heading "Title"
201    /// //   - iframe "Widget Frame" [frame-boundary]
202    /// //     - document "Widget"
203    /// //       - button "Click me"
204    /// println!("{}", snapshot);
205    /// # Ok(())
206    /// # }
207    /// ```
208    ///
209    /// # Errors
210    ///
211    /// Returns an error if:
212    /// - The page is closed
213    /// - Frame tree retrieval fails
214    /// - Snapshot capture fails for the main frame
215    #[instrument(level = "debug", skip(self), fields(target_id = %self.target_id))]
216    pub async fn aria_snapshot_with_frames(&self) -> Result<AriaSnapshot, PageError> {
217        self.aria_snapshot_with_frames_and_options(SnapshotOptions::default())
218            .await
219    }
220
221    /// Capture an ARIA accessibility snapshot of the entire page including all frames,
222    /// with custom options.
223    ///
224    /// See [`aria_snapshot_with_frames`](Self::aria_snapshot_with_frames) for details.
225    ///
226    /// # Example
227    ///
228    /// ```no_run
229    /// use viewpoint_core::{Page, SnapshotOptions};
230    ///
231    /// # async fn example(page: Page) -> Result<(), viewpoint_core::CoreError> {
232    /// // Skip ref resolution for faster capture
233    /// let options = SnapshotOptions::default().include_refs(false);
234    /// let snapshot = page.aria_snapshot_with_frames_and_options(options).await?;
235    /// # Ok(())
236    /// # }
237    /// ```
238    #[instrument(level = "debug", skip(self, options), fields(target_id = %self.target_id))]
239    pub async fn aria_snapshot_with_frames_and_options(
240        &self,
241        options: SnapshotOptions,
242    ) -> Result<AriaSnapshot, PageError> {
243        if self.closed {
244            return Err(PageError::Closed);
245        }
246
247        // Get the main frame snapshot first
248        let main_frame = self.main_frame().await?;
249        let mut root_snapshot = main_frame
250            .aria_snapshot_with_options(options.clone())
251            .await?;
252
253        // Get all frames
254        let frames = self.frames().await?;
255
256        // Filter to non-main frames
257        let child_frames: Vec<_> = frames.iter().filter(|f| !f.is_main()).collect();
258
259        if child_frames.is_empty() {
260            return Ok(root_snapshot);
261        }
262
263        debug!(
264            frame_count = child_frames.len(),
265            "Capturing child frame snapshots in parallel"
266        );
267
268        // Capture all child frame snapshots in parallel
269        let frame_futures: FuturesUnordered<_> = child_frames
270            .iter()
271            .map(|frame| {
272                let frame_id = frame.id().to_string();
273                let frame_url = frame.url().clone();
274                let frame_name = frame.name().clone();
275                let opts = options.clone();
276                async move {
277                    match frame.aria_snapshot_with_options(opts).await {
278                        Ok(snapshot) => Some((frame_id, frame_url, frame_name, snapshot)),
279                        Err(e) => {
280                            warn!(
281                                error = %e,
282                                frame_id = %frame_id,
283                                frame_url = %frame_url,
284                                "Failed to capture frame snapshot, skipping"
285                            );
286                            None
287                        }
288                    }
289                }
290            })
291            .collect();
292
293        // Collect results
294        let results: Vec<_> = frame_futures.collect().await;
295
296        // Build a map of frame URL/name to captured snapshots
297        let mut frame_snapshots: HashMap<String, AriaSnapshot> = HashMap::new();
298
299        for result in results.into_iter().flatten() {
300            let (frame_id, frame_url, frame_name, snapshot) = result;
301
302            if !frame_url.is_empty() && frame_url != "about:blank" {
303                frame_snapshots.insert(frame_url, snapshot.clone());
304            }
305            if !frame_name.is_empty() {
306                frame_snapshots.insert(frame_name, snapshot.clone());
307            }
308            // Also store by frame ID
309            frame_snapshots.insert(frame_id, snapshot);
310        }
311
312        // Stitch frame content into the snapshot
313        stitch_frame_content(&mut root_snapshot, &frame_snapshots, 0);
314
315        Ok(root_snapshot)
316    }
317
318    /// Capture an ARIA accessibility snapshot of just the main frame.
319    ///
320    /// This is a convenience method equivalent to calling `main_frame().await?.aria_snapshot().await`.
321    /// Unlike `aria_snapshot_with_frames()`, this does NOT stitch in iframe content -
322    /// iframes are left as boundaries with `is_frame: true`.
323    ///
324    /// # Node References
325    ///
326    /// The snapshot includes `node_ref` on each element (format: `e{backendNodeId}`).
327    /// These refs can be used with `element_from_ref()` or `locator_from_ref()` to
328    /// interact with elements discovered in the snapshot.
329    ///
330    /// # Example
331    ///
332    /// ```no_run
333    /// use viewpoint_core::Page;
334    ///
335    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
336    /// // Quick snapshot without frame content
337    /// let snapshot = page.aria_snapshot().await?;
338    ///
339    /// // Each element has a ref for interaction
340    /// if let Some(ref node_ref) = snapshot.node_ref {
341    ///     let locator = page.locator_from_ref(node_ref);
342    ///     locator.click().await?;
343    /// }
344    ///
345    /// // Check if there are frame boundaries to expand
346    /// if !snapshot.iframe_refs.is_empty() {
347    ///     println!("Page has {} frames that can be expanded", snapshot.iframe_refs.len());
348    /// }
349    /// # Ok(())
350    /// # }
351    /// ```
352    ///
353    /// # Errors
354    ///
355    /// Returns an error if:
356    /// - The page is closed
357    /// - Snapshot capture fails
358    #[instrument(level = "debug", skip(self), fields(target_id = %self.target_id))]
359    pub async fn aria_snapshot(&self) -> Result<AriaSnapshot, PageError> {
360        self.aria_snapshot_with_options(SnapshotOptions::default())
361            .await
362    }
363
364    /// Capture an ARIA accessibility snapshot with custom options.
365    ///
366    /// See [`aria_snapshot`](Self::aria_snapshot) for details.
367    ///
368    /// # Example
369    ///
370    /// ```no_run
371    /// use viewpoint_core::{Page, SnapshotOptions};
372    ///
373    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
374    /// // Skip ref resolution for maximum performance
375    /// let options = SnapshotOptions::default().include_refs(false);
376    /// let snapshot = page.aria_snapshot_with_options(options).await?;
377    ///
378    /// // Increase concurrency for fast networks
379    /// let options = SnapshotOptions::default().max_concurrency(100);
380    /// let snapshot = page.aria_snapshot_with_options(options).await?;
381    /// # Ok(())
382    /// # }
383    /// ```
384    #[instrument(level = "debug", skip(self, options), fields(target_id = %self.target_id))]
385    pub async fn aria_snapshot_with_options(
386        &self,
387        options: SnapshotOptions,
388    ) -> Result<AriaSnapshot, PageError> {
389        if self.closed {
390            return Err(PageError::Closed);
391        }
392
393        // Capture snapshot with element collection for ref resolution
394        self.capture_snapshot_with_refs(options).await
395    }
396
397    /// Internal method to capture a snapshot with refs resolved.
398    ///
399    /// This uses a two-phase approach:
400    /// 1. JS traversal collects the snapshot and element references
401    /// 2. CDP calls resolve each element to its backendNodeId (in parallel)
402    ///
403    /// # Performance Optimizations
404    ///
405    /// - Uses `Runtime.getProperties` to batch-fetch all array element object IDs
406    /// - Uses `FuturesUnordered` to resolve node IDs in parallel
407    /// - Configurable concurrency limit to avoid overwhelming the browser
408    #[instrument(level = "debug", skip(self, options), fields(target_id = %self.target_id))]
409    async fn capture_snapshot_with_refs(
410        &self,
411        options: SnapshotOptions,
412    ) -> Result<AriaSnapshot, PageError> {
413        let snapshot_fn = aria_snapshot_with_refs_js();
414
415        // Evaluate the JS function to get snapshot and element array
416        // We return by value for the snapshot, but need remote objects for elements
417        let js_code = js! {
418            (function() {
419                const getSnapshotWithRefs = @{snapshot_fn};
420                return getSnapshotWithRefs(document.body);
421            })()
422        };
423
424        // First, evaluate to get the result as a RemoteObject (not by value)
425        // so we can access the elements array
426        let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
427            .connection()
428            .send_command(
429                "Runtime.evaluate",
430                Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
431                    expression: js_code,
432                    object_group: Some("viewpoint-snapshot".to_string()),
433                    include_command_line_api: None,
434                    silent: Some(true),
435                    context_id: None,
436                    return_by_value: Some(false), // Get RemoteObject, not value
437                    await_promise: Some(false),
438                }),
439                Some(self.session_id()),
440            )
441            .await?;
442
443        if let Some(exception) = result.exception_details {
444            return Err(PageError::EvaluationFailed(exception.text));
445        }
446
447        let result_object_id = result.result.object_id.ok_or_else(|| {
448            PageError::EvaluationFailed("No object ID from snapshot evaluation".to_string())
449        })?;
450
451        // Get the snapshot property (by value)
452        let snapshot_value = self
453            .get_property_value(&result_object_id, "snapshot")
454            .await?;
455
456        // Parse the snapshot
457        let mut snapshot: AriaSnapshot = serde_json::from_value(snapshot_value).map_err(|e| {
458            PageError::EvaluationFailed(format!("Failed to parse aria snapshot: {e}"))
459        })?;
460
461        // Only resolve refs if requested
462        if options.include_refs {
463            // Get the elements array as a RemoteObject
464            let elements_result = self
465                .get_property_object(&result_object_id, "elements")
466                .await?;
467
468            if let Some(elements_object_id) = elements_result {
469                // Batch-fetch all array element object IDs using Runtime.getProperties
470                let element_object_ids = self
471                    .get_all_array_elements(&elements_object_id)
472                    .await?;
473                let element_count = element_object_ids.len();
474
475                debug!(
476                    element_count = element_count,
477                    max_concurrency = options.max_concurrency,
478                    "Resolving element refs in parallel"
479                );
480
481                // Resolve all node IDs in parallel with concurrency limit
482                let ref_map = self
483                    .resolve_node_ids_parallel(element_object_ids, options.max_concurrency)
484                    .await;
485
486                debug!(
487                    resolved_count = ref_map.len(),
488                    total_count = element_count,
489                    "Completed parallel ref resolution"
490                );
491
492                // Apply refs to the snapshot tree
493                apply_refs_to_snapshot(&mut snapshot, &ref_map);
494
495                // Release the elements array to free memory
496                let _ = self.release_object(&elements_object_id).await;
497            }
498        }
499
500        // Release the result object
501        let _ = self.release_object(&result_object_id).await;
502
503        Ok(snapshot)
504    }
505
506    /// Batch-fetch all array element object IDs using `Runtime.getProperties`.
507    ///
508    /// This replaces N individual `get_array_element()` calls with a single CDP call,
509    /// significantly reducing round-trips for large arrays.
510    async fn get_all_array_elements(
511        &self,
512        array_object_id: &str,
513    ) -> Result<Vec<(usize, String)>, PageError> {
514        #[derive(Debug, serde::Deserialize)]
515        struct PropertyDescriptor {
516            name: String,
517            value: Option<viewpoint_cdp::protocol::runtime::RemoteObject>,
518        }
519
520        #[derive(Debug, serde::Deserialize)]
521        struct GetPropertiesResult {
522            result: Vec<PropertyDescriptor>,
523        }
524
525        let result: GetPropertiesResult = self
526            .connection()
527            .send_command(
528                "Runtime.getProperties",
529                Some(serde_json::json!({
530                    "objectId": array_object_id,
531                    "ownProperties": true,
532                    "generatePreview": false
533                })),
534                Some(self.session_id()),
535            )
536            .await?;
537
538        // Filter to numeric indices and extract object IDs
539        let mut elements: Vec<(usize, String)> = Vec::new();
540
541        for prop in result.result {
542            // Parse numeric indices (array elements)
543            if let Ok(index) = prop.name.parse::<usize>() {
544                if let Some(value) = prop.value {
545                    if let Some(object_id) = value.object_id {
546                        elements.push((index, object_id));
547                    }
548                }
549            }
550        }
551
552        // Sort by index to maintain order
553        elements.sort_by_key(|(index, _)| *index);
554
555        trace!(element_count = elements.len(), "Batch-fetched array elements");
556
557        Ok(elements)
558    }
559
560    /// Resolve node IDs in parallel with a concurrency limit.
561    ///
562    /// Uses chunked processing with `FuturesUnordered` to limit concurrency
563    /// and avoid overwhelming the browser's CDP connection.
564    async fn resolve_node_ids_parallel(
565        &self,
566        element_object_ids: Vec<(usize, String)>,
567        max_concurrency: usize,
568    ) -> HashMap<usize, BackendNodeId> {
569        let mut ref_map = HashMap::new();
570
571        // Process in chunks to limit concurrency
572        for chunk in element_object_ids.chunks(max_concurrency) {
573            let futures: FuturesUnordered<_> = chunk
574                .iter()
575                .map(|(index, object_id)| {
576                    let index = *index;
577                    let object_id = object_id.clone();
578                    async move {
579                        match self.describe_node(&object_id).await {
580                            Ok(backend_node_id) => {
581                                trace!(
582                                    index = index,
583                                    backend_node_id = backend_node_id,
584                                    "Resolved element ref"
585                                );
586                                Some((index, backend_node_id))
587                            }
588                            Err(e) => {
589                                debug!(index = index, error = %e, "Failed to get backendNodeId for element");
590                                None
591                            }
592                        }
593                    }
594                })
595                .collect();
596
597            // Collect all results from this chunk
598            let results: Vec<_> = futures.collect().await;
599            for result in results.into_iter().flatten() {
600                ref_map.insert(result.0, result.1);
601            }
602        }
603
604        ref_map
605    }
606
607    /// Get a property value from a RemoteObject by name.
608    async fn get_property_value(
609        &self,
610        object_id: &str,
611        property: &str,
612    ) -> Result<serde_json::Value, PageError> {
613        #[derive(Debug, serde::Deserialize)]
614        struct CallResult {
615            result: viewpoint_cdp::protocol::runtime::RemoteObject,
616        }
617
618        let result: CallResult = self
619            .connection()
620            .send_command(
621                "Runtime.callFunctionOn",
622                Some(serde_json::json!({
623                    "objectId": object_id,
624                    "functionDeclaration": format!("function() {{ return this.{}; }}", property),
625                    "returnByValue": true
626                })),
627                Some(self.session_id()),
628            )
629            .await?;
630
631        Ok(result.result.value.unwrap_or(serde_json::Value::Null))
632    }
633
634    /// Get a property as a RemoteObject from a RemoteObject by name.
635    async fn get_property_object(
636        &self,
637        object_id: &str,
638        property: &str,
639    ) -> Result<Option<String>, PageError> {
640        #[derive(Debug, serde::Deserialize)]
641        struct CallResult {
642            result: viewpoint_cdp::protocol::runtime::RemoteObject,
643        }
644
645        let result: CallResult = self
646            .connection()
647            .send_command(
648                "Runtime.callFunctionOn",
649                Some(serde_json::json!({
650                    "objectId": object_id,
651                    "functionDeclaration": format!("function() {{ return this.{}; }}", property),
652                    "returnByValue": false
653                })),
654                Some(self.session_id()),
655            )
656            .await?;
657
658        Ok(result.result.object_id)
659    }
660
661    /// Get the backendNodeId for an element by its object ID.
662    async fn describe_node(&self, object_id: &str) -> Result<BackendNodeId, PageError> {
663        let result: DescribeNodeResult = self
664            .connection()
665            .send_command(
666                "DOM.describeNode",
667                Some(DescribeNodeParams {
668                    node_id: None,
669                    backend_node_id: None,
670                    object_id: Some(object_id.to_string()),
671                    depth: Some(0),
672                    pierce: None,
673                }),
674                Some(self.session_id()),
675            )
676            .await?;
677
678        Ok(result.node.backend_node_id)
679    }
680
681    /// Release a RemoteObject by its object ID.
682    async fn release_object(&self, object_id: &str) -> Result<(), PageError> {
683        let _: serde_json::Value = self
684            .connection()
685            .send_command(
686                "Runtime.releaseObject",
687                Some(serde_json::json!({
688                    "objectId": object_id
689                })),
690                Some(self.session_id()),
691            )
692            .await?;
693
694        Ok(())
695    }
696}
697
698/// Recursively apply refs to the snapshot tree based on element indices.
699///
700/// This function is used by both Page and Frame implementations to resolve
701/// element references after capturing an aria snapshot with element indices.
702pub(crate) fn apply_refs_to_snapshot(snapshot: &mut AriaSnapshot, ref_map: &HashMap<usize, BackendNodeId>) {
703    // Apply ref if this node has an element_index
704    if let Some(index) = snapshot.element_index {
705        if let Some(&backend_node_id) = ref_map.get(&index) {
706            snapshot.node_ref = Some(format_ref(backend_node_id));
707        }
708        // Clear the element_index now that we've processed it
709        snapshot.element_index = None;
710    }
711
712    // Recursively process children
713    for child in &mut snapshot.children {
714        apply_refs_to_snapshot(child, ref_map);
715    }
716}
717
718/// Recursively stitch frame content into aria snapshot at iframe boundaries.
719///
720/// This function traverses the snapshot tree looking for nodes with `is_frame: true`.
721/// When found, it attempts to find the corresponding frame snapshot and adds that
722/// content as children of the iframe node.
723fn stitch_frame_content(
724    snapshot: &mut AriaSnapshot,
725    frame_snapshots: &HashMap<String, AriaSnapshot>,
726    depth: usize,
727) {
728    // Prevent infinite recursion - max depth of 10 nested frames
729    const MAX_DEPTH: usize = 10;
730    if depth > MAX_DEPTH {
731        warn!(
732            depth = depth,
733            "Max frame nesting depth exceeded, stopping recursion"
734        );
735        return;
736    }
737
738    // If this is a frame boundary, try to get its content
739    if snapshot.is_frame == Some(true) {
740        // Try to find the matching frame snapshot
741        let frame_snapshot = snapshot
742            .frame_url
743            .as_ref()
744            .and_then(|url| frame_snapshots.get(url))
745            .or_else(|| {
746                snapshot
747                    .frame_name
748                    .as_ref()
749                    .and_then(|name| frame_snapshots.get(name))
750            });
751
752        if let Some(frame_content) = frame_snapshot {
753            debug!(
754                frame_url = ?snapshot.frame_url,
755                frame_name = ?snapshot.frame_name,
756                depth = depth,
757                "Stitching frame content into snapshot"
758            );
759
760            // Add the frame's content as children of this iframe node
761            // Clear is_frame to prevent re-processing this boundary
762            snapshot.is_frame = Some(false);
763            snapshot.children = vec![frame_content.clone()];
764        } else {
765            debug!(
766                frame_url = ?snapshot.frame_url,
767                frame_name = ?snapshot.frame_name,
768                "No matching frame snapshot found for iframe boundary"
769            );
770        }
771    }
772
773    // Recursively process children
774    for child in &mut snapshot.children {
775        stitch_frame_content(child, frame_snapshots, depth + 1);
776    }
777}