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}