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}