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//! # Frame Boundary Handling
8//!
9//! When capturing aria snapshots, iframes are marked as frame boundaries with
10//! `is_frame: true`. The `aria_snapshot_with_frames()` method captures snapshots
11//! from all frames and stitches them together at the iframe boundaries.
12//!
13//! # Cross-Origin Limitations
14//!
15//! Due to browser security restrictions:
16//! - Same-origin iframes: Content is fully captured and stitched
17//! - Cross-origin iframes: Marked as boundaries with `is_frame: true` but content
18//!   may be limited or empty depending on CDP permissions
19//!
20//! # Example
21//!
22//! ```no_run
23//! use viewpoint_core::Page;
24//!
25//! # async fn example(page: Page) -> Result<(), viewpoint_core::CoreError> {
26//! // Capture complete accessibility tree including iframes
27//! let snapshot = page.aria_snapshot_with_frames().await?;
28//! println!("{}", snapshot);
29//!
30//! // The snapshot will include all frame content stitched together
31//! // Iframes are represented with their content inline
32//! # Ok(())
33//! # }
34//! ```
35
36use std::collections::HashMap;
37
38use tracing::{debug, instrument, trace, warn};
39use viewpoint_cdp::protocol::dom::{BackendNodeId, DescribeNodeParams, DescribeNodeResult};
40use viewpoint_js::js;
41
42use super::locator::aria_js::aria_snapshot_with_refs_js;
43use super::locator::AriaSnapshot;
44use super::ref_resolution::format_ref;
45use super::Page;
46use crate::error::PageError;
47
48impl Page {
49    /// Capture an ARIA accessibility snapshot of the entire page including all frames.
50    ///
51    /// This method captures the accessibility tree of the main frame and all child
52    /// frames (iframes), then stitches them together into a single tree. Frame
53    /// boundaries in the main frame snapshot are replaced with the actual content
54    /// from the corresponding frames.
55    ///
56    /// # Frame Content Stitching
57    ///
58    /// The method works by:
59    /// 1. Capturing the main frame's aria snapshot (which marks iframes as boundaries)
60    /// 2. Getting the frame tree from CDP
61    /// 3. For each child frame, capturing its aria snapshot
62    /// 4. Stitching child frame content into the parent snapshot at iframe boundaries
63    ///
64    /// # Cross-Origin Frames
65    ///
66    /// For cross-origin frames, CDP may still be able to capture content through
67    /// out-of-process iframe (OOPIF) handling. However, some content may be
68    /// inaccessible due to browser security policies. In such cases, the frame
69    /// boundary will remain with `is_frame: true` but may have limited or no children.
70    ///
71    /// # Example
72    ///
73    /// ```no_run
74    /// use viewpoint_core::Page;
75    ///
76    /// # async fn example(page: Page) -> Result<(), viewpoint_core::CoreError> {
77    /// let snapshot = page.aria_snapshot_with_frames().await?;
78    ///
79    /// // The snapshot YAML output will show frame content inline:
80    /// // - document "Main Page"
81    /// //   - heading "Title"
82    /// //   - iframe "Widget Frame" [frame-boundary]
83    /// //     - document "Widget"
84    /// //       - button "Click me"
85    /// println!("{}", snapshot);
86    /// # Ok(())
87    /// # }
88    /// ```
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if:
93    /// - The page is closed
94    /// - Frame tree retrieval fails
95    /// - Snapshot capture fails for the main frame
96    #[instrument(level = "debug", skip(self), fields(target_id = %self.target_id))]
97    pub async fn aria_snapshot_with_frames(&self) -> Result<AriaSnapshot, PageError> {
98        if self.closed {
99            return Err(PageError::Closed);
100        }
101
102        // Get the main frame snapshot first
103        let main_frame = self.main_frame().await?;
104        let mut root_snapshot = main_frame.aria_snapshot().await?;
105
106        // Get all frames
107        let frames = self.frames().await?;
108
109        // Build a map of frame URL/name to captured snapshots
110        let mut frame_snapshots: HashMap<String, AriaSnapshot> = HashMap::new();
111
112        for frame in &frames {
113            if !frame.is_main() {
114                // Capture snapshot for this frame
115                match frame.aria_snapshot().await {
116                    Ok(snapshot) => {
117                        let url = frame.url();
118                        if !url.is_empty() && url != "about:blank" {
119                            frame_snapshots.insert(url.clone(), snapshot.clone());
120                        }
121                        let name = frame.name();
122                        if !name.is_empty() {
123                            frame_snapshots.insert(name.clone(), snapshot.clone());
124                        }
125                        // Also store by frame ID
126                        frame_snapshots.insert(frame.id().to_string(), snapshot);
127                    }
128                    Err(e) => {
129                        warn!(
130                            error = %e,
131                            frame_id = %frame.id(),
132                            frame_url = %frame.url(),
133                            "Failed to capture frame snapshot, skipping"
134                        );
135                    }
136                }
137            }
138        }
139
140        // Stitch frame content into the snapshot
141        stitch_frame_content(&mut root_snapshot, &frame_snapshots, 0);
142
143        Ok(root_snapshot)
144    }
145
146    /// Capture an ARIA accessibility snapshot of just the main frame.
147    ///
148    /// This is a convenience method equivalent to calling `main_frame().await?.aria_snapshot().await`.
149    /// Unlike `aria_snapshot_with_frames()`, this does NOT stitch in iframe content -
150    /// iframes are left as boundaries with `is_frame: true`.
151    ///
152    /// # Node References
153    ///
154    /// The snapshot includes `node_ref` on each element (format: `e{backendNodeId}`).
155    /// These refs can be used with `element_from_ref()` or `locator_from_ref()` to
156    /// interact with elements discovered in the snapshot.
157    ///
158    /// # Example
159    ///
160    /// ```no_run
161    /// use viewpoint_core::Page;
162    ///
163    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
164    /// // Quick snapshot without frame content
165    /// let snapshot = page.aria_snapshot().await?;
166    ///
167    /// // Each element has a ref for interaction
168    /// if let Some(ref node_ref) = snapshot.node_ref {
169    ///     let locator = page.locator_from_ref(node_ref);
170    ///     locator.click().await?;
171    /// }
172    ///
173    /// // Check if there are frame boundaries to expand
174    /// if !snapshot.iframe_refs.is_empty() {
175    ///     println!("Page has {} frames that can be expanded", snapshot.iframe_refs.len());
176    /// }
177    /// # Ok(())
178    /// # }
179    /// ```
180    ///
181    /// # Errors
182    ///
183    /// Returns an error if:
184    /// - The page is closed
185    /// - Snapshot capture fails
186    #[instrument(level = "debug", skip(self), fields(target_id = %self.target_id))]
187    pub async fn aria_snapshot(&self) -> Result<AriaSnapshot, PageError> {
188        if self.closed {
189            return Err(PageError::Closed);
190        }
191
192        // Capture snapshot with element collection for ref resolution
193        self.capture_snapshot_with_refs().await
194    }
195
196    /// Internal method to capture a snapshot with refs resolved.
197    ///
198    /// This uses a two-phase approach:
199    /// 1. JS traversal collects the snapshot and element references
200    /// 2. CDP calls resolve each element to its backendNodeId
201    #[instrument(level = "debug", skip(self), fields(target_id = %self.target_id))]
202    async fn capture_snapshot_with_refs(&self) -> Result<AriaSnapshot, PageError> {
203        let snapshot_fn = aria_snapshot_with_refs_js();
204
205        // Evaluate the JS function to get snapshot and element array
206        // We return by value for the snapshot, but need remote objects for elements
207        let js_code = js! {
208            (function() {
209                const getSnapshotWithRefs = @{snapshot_fn};
210                return getSnapshotWithRefs(document.body);
211            })()
212        };
213
214        // First, evaluate to get the result as a RemoteObject (not by value)
215        // so we can access the elements array
216        let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
217            .connection()
218            .send_command(
219                "Runtime.evaluate",
220                Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
221                    expression: js_code,
222                    object_group: Some("viewpoint-snapshot".to_string()),
223                    include_command_line_api: None,
224                    silent: Some(true),
225                    context_id: None,
226                    return_by_value: Some(false), // Get RemoteObject, not value
227                    await_promise: Some(false),
228                }),
229                Some(self.session_id()),
230            )
231            .await?;
232
233        if let Some(exception) = result.exception_details {
234            return Err(PageError::EvaluationFailed(exception.text));
235        }
236
237        let result_object_id = result.result.object_id.ok_or_else(|| {
238            PageError::EvaluationFailed("No object ID from snapshot evaluation".to_string())
239        })?;
240
241        // Get the snapshot property (by value)
242        let snapshot_value = self.get_property_value(&result_object_id, "snapshot").await?;
243        
244        // Parse the snapshot
245        let mut snapshot: AriaSnapshot = serde_json::from_value(snapshot_value).map_err(|e| {
246            PageError::EvaluationFailed(format!("Failed to parse aria snapshot: {e}"))
247        })?;
248
249        // Get the elements array as a RemoteObject
250        let elements_result = self.get_property_object(&result_object_id, "elements").await?;
251        
252        if let Some(elements_object_id) = elements_result {
253            // Get the length of the elements array
254            let length_value = self.get_property_value(&elements_object_id, "length").await?;
255            let element_count = length_value.as_u64().unwrap_or(0) as usize;
256            
257            debug!(element_count = element_count, "Resolving element refs");
258
259            // Build a map of element index -> backendNodeId
260            let mut ref_map: HashMap<usize, BackendNodeId> = HashMap::new();
261
262            for i in 0..element_count {
263                // Get the element at index i
264                if let Ok(Some(element_object_id)) = self.get_array_element(&elements_object_id, i).await {
265                    // Get the backendNodeId for this element
266                    match self.describe_node(&element_object_id).await {
267                        Ok(backend_node_id) => {
268                            ref_map.insert(i, backend_node_id);
269                            trace!(index = i, backend_node_id = backend_node_id, "Resolved element ref");
270                        }
271                        Err(e) => {
272                            debug!(index = i, error = %e, "Failed to get backendNodeId for element");
273                        }
274                    }
275                }
276            }
277
278            // Apply refs to the snapshot tree
279            apply_refs_to_snapshot(&mut snapshot, &ref_map);
280
281            // Release the elements array to free memory
282            let _ = self.release_object(&elements_object_id).await;
283        }
284
285        // Release the result object
286        let _ = self.release_object(&result_object_id).await;
287
288        Ok(snapshot)
289    }
290
291    /// Get a property value from a RemoteObject by name.
292    async fn get_property_value(
293        &self,
294        object_id: &str,
295        property: &str,
296    ) -> Result<serde_json::Value, PageError> {
297        #[derive(Debug, serde::Deserialize)]
298        struct CallResult {
299            result: viewpoint_cdp::protocol::runtime::RemoteObject,
300        }
301
302        let result: CallResult = self
303            .connection()
304            .send_command(
305                "Runtime.callFunctionOn",
306                Some(serde_json::json!({
307                    "objectId": object_id,
308                    "functionDeclaration": format!("function() {{ return this.{}; }}", property),
309                    "returnByValue": true
310                })),
311                Some(self.session_id()),
312            )
313            .await?;
314
315        Ok(result.result.value.unwrap_or(serde_json::Value::Null))
316    }
317
318    /// Get a property as a RemoteObject from a RemoteObject by name.
319    async fn get_property_object(
320        &self,
321        object_id: &str,
322        property: &str,
323    ) -> Result<Option<String>, PageError> {
324        #[derive(Debug, serde::Deserialize)]
325        struct CallResult {
326            result: viewpoint_cdp::protocol::runtime::RemoteObject,
327        }
328
329        let result: CallResult = self
330            .connection()
331            .send_command(
332                "Runtime.callFunctionOn",
333                Some(serde_json::json!({
334                    "objectId": object_id,
335                    "functionDeclaration": format!("function() {{ return this.{}; }}", property),
336                    "returnByValue": false
337                })),
338                Some(self.session_id()),
339            )
340            .await?;
341
342        Ok(result.result.object_id)
343    }
344
345    /// Get an element from an array by index.
346    async fn get_array_element(
347        &self,
348        array_object_id: &str,
349        index: usize,
350    ) -> Result<Option<String>, PageError> {
351        #[derive(Debug, serde::Deserialize)]
352        struct CallResult {
353            result: viewpoint_cdp::protocol::runtime::RemoteObject,
354        }
355
356        let result: CallResult = self
357            .connection()
358            .send_command(
359                "Runtime.callFunctionOn",
360                Some(serde_json::json!({
361                    "objectId": array_object_id,
362                    "functionDeclaration": format!("function() {{ return this[{}]; }}", index),
363                    "returnByValue": false
364                })),
365                Some(self.session_id()),
366            )
367            .await?;
368
369        Ok(result.result.object_id)
370    }
371
372    /// Get the backendNodeId for an element by its object ID.
373    async fn describe_node(&self, object_id: &str) -> Result<BackendNodeId, PageError> {
374        let result: DescribeNodeResult = self
375            .connection()
376            .send_command(
377                "DOM.describeNode",
378                Some(DescribeNodeParams {
379                    node_id: None,
380                    backend_node_id: None,
381                    object_id: Some(object_id.to_string()),
382                    depth: Some(0),
383                    pierce: None,
384                }),
385                Some(self.session_id()),
386            )
387            .await?;
388
389        Ok(result.node.backend_node_id)
390    }
391
392    /// Release a RemoteObject by its object ID.
393    async fn release_object(&self, object_id: &str) -> Result<(), PageError> {
394        let _: serde_json::Value = self
395            .connection()
396            .send_command(
397                "Runtime.releaseObject",
398                Some(serde_json::json!({
399                    "objectId": object_id
400                })),
401                Some(self.session_id()),
402            )
403            .await?;
404
405        Ok(())
406    }
407}
408
409/// Recursively apply refs to the snapshot tree based on element indices.
410///
411/// This function is used by both Page and Frame implementations to resolve
412/// element references after capturing an aria snapshot with element indices.
413pub(crate) fn apply_refs_to_snapshot(snapshot: &mut AriaSnapshot, ref_map: &HashMap<usize, BackendNodeId>) {
414    // Apply ref if this node has an element_index
415    if let Some(index) = snapshot.element_index {
416        if let Some(&backend_node_id) = ref_map.get(&index) {
417            snapshot.node_ref = Some(format_ref(backend_node_id));
418        }
419        // Clear the element_index now that we've processed it
420        snapshot.element_index = None;
421    }
422
423    // Recursively process children
424    for child in &mut snapshot.children {
425        apply_refs_to_snapshot(child, ref_map);
426    }
427}
428
429/// Recursively stitch frame content into aria snapshot at iframe boundaries.
430///
431/// This function traverses the snapshot tree looking for nodes with `is_frame: true`.
432/// When found, it attempts to find the corresponding frame snapshot and adds that
433/// content as children of the iframe node.
434fn stitch_frame_content(
435    snapshot: &mut AriaSnapshot,
436    frame_snapshots: &HashMap<String, AriaSnapshot>,
437    depth: usize,
438) {
439    // Prevent infinite recursion - max depth of 10 nested frames
440    const MAX_DEPTH: usize = 10;
441    if depth > MAX_DEPTH {
442        warn!(
443            depth = depth,
444            "Max frame nesting depth exceeded, stopping recursion"
445        );
446        return;
447    }
448
449    // If this is a frame boundary, try to get its content
450    if snapshot.is_frame == Some(true) {
451        // Try to find the matching frame snapshot
452        let frame_snapshot = snapshot
453            .frame_url
454            .as_ref()
455            .and_then(|url| frame_snapshots.get(url))
456            .or_else(|| {
457                snapshot
458                    .frame_name
459                    .as_ref()
460                    .and_then(|name| frame_snapshots.get(name))
461            });
462
463        if let Some(frame_content) = frame_snapshot {
464            debug!(
465                frame_url = ?snapshot.frame_url,
466                frame_name = ?snapshot.frame_name,
467                depth = depth,
468                "Stitching frame content into snapshot"
469            );
470
471            // Add the frame's content as children of this iframe node
472            // Clear is_frame to prevent re-processing this boundary
473            snapshot.is_frame = Some(false);
474            snapshot.children = vec![frame_content.clone()];
475        } else {
476            debug!(
477                frame_url = ?snapshot.frame_url,
478                frame_name = ?snapshot.frame_name,
479                "No matching frame snapshot found for iframe boundary"
480            );
481        }
482    }
483
484    // Recursively process children
485    for child in &mut snapshot.children {
486        stitch_frame_content(child, frame_snapshots, depth + 1);
487    }
488}