viewpoint_core/page/frame/
aria.rs

1//! Frame ARIA accessibility snapshot operations.
2
3use std::collections::HashMap;
4
5use tracing::{debug, instrument, trace};
6use viewpoint_cdp::protocol::dom::{BackendNodeId, DescribeNodeParams, DescribeNodeResult};
7use viewpoint_cdp::protocol::runtime::EvaluateParams;
8use viewpoint_js::js;
9
10use super::Frame;
11use crate::error::PageError;
12use crate::page::aria_snapshot::apply_refs_to_snapshot;
13use crate::page::locator::aria_js::aria_snapshot_with_refs_js;
14
15impl Frame {
16    /// Capture an ARIA accessibility snapshot of this frame's document.
17    ///
18    /// The snapshot represents the accessible structure of the frame's content
19    /// as it would be exposed to assistive technologies. This is useful for
20    /// accessibility testing and MCP (Model Context Protocol) integrations.
21    ///
22    /// # Node References
23    ///
24    /// The snapshot includes `node_ref` on each element (format: `e{backendNodeId}`).
25    /// These refs can be used with `Page::element_from_ref()` or `Page::locator_from_ref()`
26    /// to interact with elements discovered in the snapshot.
27    ///
28    /// # Frame Boundaries
29    ///
30    /// Any iframes within this frame are marked as frame boundaries in the snapshot
31    /// with `is_frame: true`. Their content is NOT traversed (for security reasons).
32    /// To capture multi-frame accessibility trees, use `Page::aria_snapshot_with_frames()`.
33    ///
34    /// # Errors
35    ///
36    /// Returns an error if:
37    /// - The frame is detached
38    /// - JavaScript evaluation fails
39    /// - The snapshot cannot be parsed
40    #[instrument(level = "debug", skip(self), fields(frame_id = %self.id))]
41    pub async fn aria_snapshot(&self) -> Result<crate::page::locator::AriaSnapshot, PageError> {
42        if self.is_detached() {
43            return Err(PageError::EvaluationFailed("Frame is detached".to_string()));
44        }
45
46        // Capture snapshot with element collection for ref resolution
47        self.capture_snapshot_with_refs().await
48    }
49
50    /// Internal method to capture a snapshot with refs resolved.
51    ///
52    /// This uses a two-phase approach:
53    /// 1. JS traversal collects the snapshot and element references
54    /// 2. CDP calls resolve each element to its backendNodeId
55    #[instrument(level = "debug", skip(self), fields(frame_id = %self.id))]
56    pub(super) async fn capture_snapshot_with_refs(
57        &self,
58    ) -> Result<crate::page::locator::AriaSnapshot, PageError> {
59        let snapshot_fn = aria_snapshot_with_refs_js();
60
61        // Evaluate the JS function to get snapshot and element array
62        // We return by value for the snapshot, but need remote objects for elements
63        let js_code = js! {
64            (function() {
65                const getSnapshotWithRefs = @{snapshot_fn};
66                return getSnapshotWithRefs(document.body);
67            })()
68        };
69
70        // Get the execution context ID for this frame's main world
71        let context_id = self.main_world_context_id();
72        trace!(context_id = ?context_id, "Using execution context for aria_snapshot()");
73
74        // First, evaluate to get the result as a RemoteObject (not by value)
75        // so we can access the elements array
76        let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
77            .connection
78            .send_command(
79                "Runtime.evaluate",
80                Some(EvaluateParams {
81                    expression: js_code,
82                    object_group: Some("viewpoint-snapshot".to_string()),
83                    include_command_line_api: None,
84                    silent: Some(true),
85                    context_id,
86                    return_by_value: Some(false), // Get RemoteObject, not value
87                    await_promise: Some(false),
88                }),
89                Some(&self.session_id),
90            )
91            .await?;
92
93        if let Some(exception) = result.exception_details {
94            return Err(PageError::EvaluationFailed(exception.text));
95        }
96
97        let result_object_id = result.result.object_id.ok_or_else(|| {
98            PageError::EvaluationFailed("No object ID from snapshot evaluation".to_string())
99        })?;
100
101        // Get the snapshot property (by value)
102        let snapshot_value = self.get_property_value(&result_object_id, "snapshot").await?;
103
104        // Parse the snapshot
105        let mut snapshot: crate::page::locator::AriaSnapshot =
106            serde_json::from_value(snapshot_value).map_err(|e| {
107                PageError::EvaluationFailed(format!("Failed to parse aria snapshot: {e}"))
108            })?;
109
110        // Get the elements array as a RemoteObject
111        let elements_result = self.get_property_object(&result_object_id, "elements").await?;
112
113        if let Some(elements_object_id) = elements_result {
114            // Get the length of the elements array
115            let length_value = self
116                .get_property_value(&elements_object_id, "length")
117                .await?;
118            let element_count = length_value.as_u64().unwrap_or(0) as usize;
119
120            debug!(element_count = element_count, "Resolving element refs");
121
122            // Build a map of element index -> backendNodeId
123            let mut ref_map: HashMap<usize, BackendNodeId> = HashMap::new();
124
125            for i in 0..element_count {
126                // Get the element at index i
127                if let Ok(Some(element_object_id)) =
128                    self.get_array_element(&elements_object_id, i).await
129                {
130                    // Get the backendNodeId for this element
131                    match self.describe_node(&element_object_id).await {
132                        Ok(backend_node_id) => {
133                            ref_map.insert(i, backend_node_id);
134                            trace!(
135                                index = i,
136                                backend_node_id = backend_node_id,
137                                "Resolved element ref"
138                            );
139                        }
140                        Err(e) => {
141                            debug!(index = i, error = %e, "Failed to get backendNodeId for element");
142                        }
143                    }
144                }
145            }
146
147            // Apply refs to the snapshot tree
148            apply_refs_to_snapshot(&mut snapshot, &ref_map);
149
150            // Release the elements array to free memory
151            let _ = self.release_object(&elements_object_id).await;
152        }
153
154        // Release the result object
155        let _ = self.release_object(&result_object_id).await;
156
157        Ok(snapshot)
158    }
159
160    /// Get a property value from a RemoteObject by name.
161    pub(super) async fn get_property_value(
162        &self,
163        object_id: &str,
164        property: &str,
165    ) -> Result<serde_json::Value, PageError> {
166        #[derive(Debug, serde::Deserialize)]
167        struct CallResult {
168            result: viewpoint_cdp::protocol::runtime::RemoteObject,
169        }
170
171        let result: CallResult = self
172            .connection
173            .send_command(
174                "Runtime.callFunctionOn",
175                Some(serde_json::json!({
176                    "objectId": object_id,
177                    "functionDeclaration": format!("function() {{ return this.{}; }}", property),
178                    "returnByValue": true
179                })),
180                Some(&self.session_id),
181            )
182            .await?;
183
184        Ok(result.result.value.unwrap_or(serde_json::Value::Null))
185    }
186
187    /// Get a property as a RemoteObject from a RemoteObject by name.
188    pub(super) async fn get_property_object(
189        &self,
190        object_id: &str,
191        property: &str,
192    ) -> Result<Option<String>, PageError> {
193        #[derive(Debug, serde::Deserialize)]
194        struct CallResult {
195            result: viewpoint_cdp::protocol::runtime::RemoteObject,
196        }
197
198        let result: CallResult = self
199            .connection
200            .send_command(
201                "Runtime.callFunctionOn",
202                Some(serde_json::json!({
203                    "objectId": object_id,
204                    "functionDeclaration": format!("function() {{ return this.{}; }}", property),
205                    "returnByValue": false
206                })),
207                Some(&self.session_id),
208            )
209            .await?;
210
211        Ok(result.result.object_id)
212    }
213
214    /// Get an element from an array by index.
215    pub(super) async fn get_array_element(
216        &self,
217        array_object_id: &str,
218        index: usize,
219    ) -> Result<Option<String>, PageError> {
220        #[derive(Debug, serde::Deserialize)]
221        struct CallResult {
222            result: viewpoint_cdp::protocol::runtime::RemoteObject,
223        }
224
225        let result: CallResult = self
226            .connection
227            .send_command(
228                "Runtime.callFunctionOn",
229                Some(serde_json::json!({
230                    "objectId": array_object_id,
231                    "functionDeclaration": format!("function() {{ return this[{}]; }}", index),
232                    "returnByValue": false
233                })),
234                Some(&self.session_id),
235            )
236            .await?;
237
238        Ok(result.result.object_id)
239    }
240
241    /// Get the backendNodeId for an element by its object ID.
242    pub(super) async fn describe_node(&self, object_id: &str) -> Result<BackendNodeId, PageError> {
243        let result: DescribeNodeResult = self
244            .connection
245            .send_command(
246                "DOM.describeNode",
247                Some(DescribeNodeParams {
248                    node_id: None,
249                    backend_node_id: None,
250                    object_id: Some(object_id.to_string()),
251                    depth: Some(0),
252                    pierce: None,
253                }),
254                Some(&self.session_id),
255            )
256            .await?;
257
258        Ok(result.node.backend_node_id)
259    }
260
261    /// Release a RemoteObject by its object ID.
262    pub(super) async fn release_object(&self, object_id: &str) -> Result<(), PageError> {
263        let _: serde_json::Value = self
264            .connection
265            .send_command(
266                "Runtime.releaseObject",
267                Some(serde_json::json!({
268                    "objectId": object_id
269                })),
270                Some(&self.session_id),
271            )
272            .await?;
273
274        Ok(())
275    }
276}