viewpoint_core/page/ref_resolution/
mod.rs

1//! Node reference resolution for ARIA snapshots.
2//!
3//! This module provides functionality to resolve element references from
4//! ARIA snapshots back to DOM elements for interaction.
5//!
6//! # Reference Format
7//!
8//! Element references follow the format `c{contextIndex}p{pageIndex}f{frameIndex}e{counter}` where:
9//!
10//! - `c{contextIndex}` - Which browser context this ref belongs to
11//! - `p{pageIndex}` - Which page/tab within the context this ref belongs to
12//! - `f{frameIndex}` - Which frame within the page (0 = main frame, 1+ = child frames)
13//! - `e{counter}` - Simple incrementing counter per snapshot
14//!
15//! For example: `c0p0f0e1`, `c0p0f0e2`, `c0p0f1e1`, `c1p0f0e1`
16//!
17//! This format:
18//! - Prevents ref collisions across contexts, pages, and frames
19//! - Is short and readable
20//! - Enables validation of refs against the correct context and page
21//!
22//! # MCP Server Usage
23//!
24//! This feature is designed for MCP (Model Context Protocol) servers that need to:
25//!
26//! 1. Present an accessibility tree to AI/users
27//! 2. Allow interaction with any element in that tree
28//!
29//! Without refs, users would need to re-query elements by role/name, which is fragile
30//! when multiple elements share the same accessible properties.
31//!
32//! # Example: Click a Button by Ref
33//!
34//! ```no_run
35//! use viewpoint_core::Page;
36//!
37//! # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
38//! // Capture snapshot with refs
39//! let snapshot = page.aria_snapshot().await?;
40//!
41//! // Find a button's ref in the snapshot
42//! if let Some(ref node_ref) = snapshot.node_ref.as_ref() {
43//!     // Resolve ref to element handle (for low-level operations)
44//!     let handle = page.element_from_ref(node_ref).await?;
45//!
46//!     // Or get a locator for auto-waiting behavior (preferred)
47//!     let locator = page.locator_from_ref(node_ref);
48//!     locator.click().await?;
49//! }
50//! # Ok(())
51//! # }
52//! ```
53//!
54//! # Example: Find and Interact with Snapshot Elements
55//!
56//! ```no_run
57//! use viewpoint_core::{Page, AriaSnapshot};
58//!
59//! # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
60//! // Capture snapshot
61//! let snapshot = page.aria_snapshot().await?;
62//!
63//! // Helper to find a button by name
64//! fn find_button_ref(snapshot: &AriaSnapshot, name: &str) -> Option<String> {
65//!     if snapshot.role.as_deref() == Some("button")
66//!         && snapshot.name.as_deref() == Some(name)
67//!     {
68//!         return snapshot.node_ref.clone();
69//!     }
70//!     for child in &snapshot.children {
71//!         if let Some(r) = find_button_ref(child, name) {
72//!             return Some(r);
73//!         }
74//!     }
75//!     None
76//! }
77//!
78//! // Find "Submit" button and click it
79//! if let Some(submit_ref) = find_button_ref(&snapshot, "Submit") {
80//!     page.locator_from_ref(&submit_ref).click().await?;
81//! }
82//! # Ok(())
83//! # }
84//! ```
85
86use tracing::{debug, instrument};
87use viewpoint_cdp::protocol::dom::{
88    BackendNodeId, DescribeNodeParams, DescribeNodeResult, ResolveNodeParams, ResolveNodeResult,
89};
90
91use super::Page;
92use super::locator::ElementHandle;
93use crate::error::{LocatorError, PageError};
94
95/// Parsed element reference with context, page, frame, and element indices.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub struct ParsedRef {
98    /// Context index.
99    pub context_index: usize,
100    /// Page index within the context.
101    pub page_index: usize,
102    /// Frame index within the page (0 = main frame, 1+ = child frames).
103    pub frame_index: usize,
104    /// Element counter within the snapshot.
105    pub element_counter: usize,
106}
107
108impl ParsedRef {
109    /// Create a new parsed ref.
110    pub fn new(context_index: usize, page_index: usize, frame_index: usize, element_counter: usize) -> Self {
111        Self {
112            context_index,
113            page_index,
114            frame_index,
115            element_counter,
116        }
117    }
118}
119
120/// Parse a ref string to extract context, page, frame, and element indices.
121///
122/// Refs are formatted as `c{contextIndex}p{pageIndex}f{frameIndex}e{counter}`, e.g., `c0p0f0e1`.
123///
124/// # Errors
125///
126/// Returns `LocatorError::EvaluationError` if the ref format is invalid.
127pub fn parse_ref(ref_str: &str) -> Result<ParsedRef, LocatorError> {
128    if !ref_str.starts_with('c') {
129        return Err(LocatorError::EvaluationError(format!(
130            "Invalid ref format: expected 'c{{ctx}}p{{page}}f{{frame}}e{{counter}}', got '{ref_str}'"
131        )));
132    }
133
134    parse_ref_format(ref_str)
135}
136
137/// Parse the ref format: c{contextIndex}p{pageIndex}f{frameIndex}e{counter}
138fn parse_ref_format(ref_str: &str) -> Result<ParsedRef, LocatorError> {
139    // Format: c0p0f0e1
140    let without_c = ref_str.strip_prefix('c').ok_or_else(|| {
141        LocatorError::EvaluationError(format!("Invalid ref format: expected 'c' prefix in '{ref_str}'"))
142    })?;
143
144    let (context_part, rest) = without_c.split_once('p').ok_or_else(|| {
145        LocatorError::EvaluationError(format!("Invalid ref format: expected 'p' separator in '{ref_str}'"))
146    })?;
147
148    let (page_part, rest) = rest.split_once('f').ok_or_else(|| {
149        LocatorError::EvaluationError(format!("Invalid ref format: expected 'f' separator in '{ref_str}'"))
150    })?;
151
152    let (frame_part, element_part) = rest.split_once('e').ok_or_else(|| {
153        LocatorError::EvaluationError(format!("Invalid ref format: expected 'e' separator in '{ref_str}'"))
154    })?;
155
156    let context_index = context_part.parse::<usize>().map_err(|e| {
157        LocatorError::EvaluationError(format!("Invalid context index in ref '{ref_str}': {e}"))
158    })?;
159
160    let page_index = page_part.parse::<usize>().map_err(|e| {
161        LocatorError::EvaluationError(format!("Invalid page index in ref '{ref_str}': {e}"))
162    })?;
163
164    let frame_index = frame_part.parse::<usize>().map_err(|e| {
165        LocatorError::EvaluationError(format!("Invalid frame index in ref '{ref_str}': {e}"))
166    })?;
167
168    let element_counter = element_part.parse::<usize>().map_err(|e| {
169        LocatorError::EvaluationError(format!("Invalid element counter in ref '{ref_str}': {e}"))
170    })?;
171
172    Ok(ParsedRef::new(context_index, page_index, frame_index, element_counter))
173}
174
175/// Format a ref string from context, page, frame, and element indices.
176///
177/// Produces the format `c{contextIndex}p{pageIndex}f{frameIndex}e{counter}`.
178pub fn format_ref(context_index: usize, page_index: usize, frame_index: usize, element_counter: usize) -> String {
179    format!("c{context_index}p{page_index}f{frame_index}e{element_counter}")
180}
181
182impl Page {
183    /// Get an element handle from a snapshot ref.
184    ///
185    /// This resolves the ref (format: `c{contextIndex}p{pageIndex}f{frameIndex}e{counter}`)
186    /// to an `ElementHandle` that can be used for low-level DOM operations.
187    ///
188    /// # Arguments
189    ///
190    /// * `ref_str` - The element ref from an ARIA snapshot (e.g., `c0p0f0e1`)
191    ///
192    /// # Example
193    ///
194    /// ```no_run
195    /// use viewpoint_core::Page;
196    ///
197    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
198    /// let snapshot = page.aria_snapshot().await?;
199    /// // Assume we found a button with ref "c0p0f0e1"
200    /// let handle = page.element_from_ref("c0p0f0e1").await?;
201    /// let text: String = handle.evaluate("this.textContent").await?;
202    /// # Ok(())
203    /// # }
204    /// ```
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if:
209    /// - The page is closed
210    /// - The ref format is invalid
211    /// - The ref is from a different context or page
212    /// - The element no longer exists (stale ref)
213    #[instrument(level = "debug", skip(self), fields(target_id = %self.target_id, ref_str = %ref_str))]
214    pub async fn element_from_ref(&self, ref_str: &str) -> Result<ElementHandle<'_>, LocatorError> {
215        if self.is_closed() {
216            return Err(LocatorError::PageClosed);
217        }
218
219        let parsed = parse_ref(ref_str)?;
220        
221        // Validate context index
222        if parsed.context_index != self.context_index {
223            return Err(LocatorError::EvaluationError(format!(
224                "Context index mismatch: ref '{ref_str}' is for context {}, but this page is in context {}",
225                parsed.context_index, self.context_index
226            )));
227        }
228        
229        // Validate page index
230        if parsed.page_index != self.page_index {
231            return Err(LocatorError::EvaluationError(format!(
232                "Page index mismatch: ref '{ref_str}' is for page {}, but this is page {}",
233                parsed.page_index, self.page_index
234            )));
235        }
236
237        debug!(
238            context_index = parsed.context_index,
239            page_index = parsed.page_index,
240            frame_index = parsed.frame_index,
241            element_counter = parsed.element_counter,
242            "Resolving ref to element"
243        );
244
245        // Look up the backendNodeId from the ref map
246        let backend_node_id = self.get_backend_node_id_for_ref(ref_str)?;
247
248        // Use DOM.resolveNode to get a RemoteObject from the backend node ID
249        let result: ResolveNodeResult = self
250            .connection()
251            .send_command(
252                "DOM.resolveNode",
253                Some(ResolveNodeParams {
254                    node_id: None,
255                    backend_node_id: Some(backend_node_id),
256                    object_group: Some("viewpoint-ref".to_string()),
257                    execution_context_id: None,
258                }),
259                Some(self.session_id()),
260            )
261            .await
262            .map_err(|e| {
263                LocatorError::NotFound(format!(
264                    "Ref not found. Capture a new snapshot. Error: {e}"
265                ))
266            })?;
267
268        let object_id = result.object.object_id.ok_or_else(|| {
269            LocatorError::NotFound("Ref not found. Capture a new snapshot.".to_string())
270        })?;
271
272        debug!(object_id = %object_id, "Resolved ref to element handle");
273
274        Ok(ElementHandle {
275            object_id,
276            page: self,
277        })
278    }
279
280    /// Create a locator from a snapshot ref.
281    ///
282    /// This creates a `Locator` that targets the element identified by the ref.
283    /// Unlike `element_from_ref`, the locator provides auto-waiting behavior
284    /// and is the preferred way to interact with elements.
285    ///
286    /// # Arguments
287    ///
288    /// * `ref_str` - The element ref from an ARIA snapshot (e.g., `c0p0f0e1`)
289    ///
290    /// # Example
291    ///
292    /// ```no_run
293    /// use viewpoint_core::Page;
294    ///
295    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
296    /// let snapshot = page.aria_snapshot().await?;
297    /// // Assume we found a button with ref "c0p0f0e1"
298    /// let locator = page.locator_from_ref("c0p0f0e1");
299    /// locator.click().await?;
300    /// # Ok(())
301    /// # }
302    /// ```
303    ///
304    /// # Panics
305    ///
306    /// Panics if the ref format is invalid. Use `element_from_ref` if you need
307    /// to handle invalid refs gracefully.
308    pub fn locator_from_ref(&self, ref_str: &str) -> super::Locator<'_> {
309        use super::locator::{Locator, Selector};
310
311        // Parse the ref to validate format and indices
312        let parsed = parse_ref(ref_str)
313            .expect("Invalid ref format. Refs must be in format 'c{ctx}p{page}f{frame}e{counter}'");
314        
315        // Validate indices match this page
316        assert!(
317            parsed.context_index == self.context_index,
318            "Context index mismatch: ref is for context {}, but this page is in context {}",
319            parsed.context_index,
320            self.context_index
321        );
322        
323        assert!(
324            parsed.page_index == self.page_index,
325            "Page index mismatch: ref is for page {}, but this is page {}",
326            parsed.page_index,
327            self.page_index
328        );
329
330        // Create a locator with a ref selector that will lookup from the ref map
331        Locator::new(self, Selector::Ref(ref_str.to_string()))
332    }
333
334    /// Get the backend node ID for an element from its object ID.
335    ///
336    /// This is a lower-level method used internally during snapshot capture
337    /// to resolve element references.
338    pub(crate) async fn get_backend_node_id(
339        &self,
340        object_id: &str,
341    ) -> Result<BackendNodeId, PageError> {
342        let result: DescribeNodeResult = self
343            .connection()
344            .send_command(
345                "DOM.describeNode",
346                Some(DescribeNodeParams {
347                    node_id: None,
348                    backend_node_id: None,
349                    object_id: Some(object_id.to_string()),
350                    depth: Some(0),
351                    pierce: None,
352                }),
353                Some(self.session_id()),
354            )
355            .await?;
356
357        Ok(result.node.backend_node_id)
358    }
359
360    /// Get the backend node ID for a ref from the ref map.
361    ///
362    /// This is used by `element_from_ref` and `locator_from_ref` to lookup
363    /// the backendNodeId for a ref captured during `aria_snapshot()`.
364    ///
365    /// # Errors
366    ///
367    /// Returns an error if the ref is not found in the ref map.
368    pub(crate) fn get_backend_node_id_for_ref(
369        &self,
370        ref_str: &str,
371    ) -> Result<BackendNodeId, LocatorError> {
372        self.ref_map
373            .read()
374            .get(ref_str)
375            .copied()
376            .ok_or_else(|| {
377                LocatorError::NotFound(
378                    "Ref not found. Capture a new snapshot.".to_string()
379                )
380            })
381    }
382
383    /// Store a ref mapping in the page's ref map.
384    ///
385    /// This is called during `aria_snapshot()` to populate the ref map
386    /// with the element refs and their corresponding backendNodeIds.
387    pub(crate) fn store_ref_mapping(&self, ref_str: String, backend_node_id: BackendNodeId) {
388        self.ref_map.write().insert(ref_str, backend_node_id);
389    }
390
391    /// Clear all ref mappings.
392    ///
393    /// This is called at the beginning of `aria_snapshot()` to clear
394    /// stale refs from a previous snapshot.
395    pub(crate) fn clear_ref_map(&self) {
396        self.ref_map.write().clear();
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn test_parse_ref_new_format() {
406        let parsed = parse_ref("c0p0f0e1").unwrap();
407        assert_eq!(parsed.context_index, 0);
408        assert_eq!(parsed.page_index, 0);
409        assert_eq!(parsed.frame_index, 0);
410        assert_eq!(parsed.element_counter, 1);
411    }
412
413    #[test]
414    fn test_parse_ref_new_format_larger_indices() {
415        let parsed = parse_ref("c12p34f56e789").unwrap();
416        assert_eq!(parsed.context_index, 12);
417        assert_eq!(parsed.page_index, 34);
418        assert_eq!(parsed.frame_index, 56);
419        assert_eq!(parsed.element_counter, 789);
420    }
421
422    #[test]
423    fn test_parse_ref_child_frame() {
424        let parsed = parse_ref("c0p0f1e5").unwrap();
425        assert_eq!(parsed.context_index, 0);
426        assert_eq!(parsed.page_index, 0);
427        assert_eq!(parsed.frame_index, 1);
428        assert_eq!(parsed.element_counter, 5);
429    }
430
431    #[test]
432    fn test_parse_ref_invalid_format() {
433        assert!(parse_ref("invalid").is_err());
434        assert!(parse_ref("x0p0f0e1").is_err());
435        assert!(parse_ref("c0p0e1").is_err()); // missing frame
436        assert!(parse_ref("c0f0e1").is_err()); // missing page
437        assert!(parse_ref("").is_err());
438    }
439
440    #[test]
441    fn test_parse_ref_legacy_format_rejected() {
442        // Legacy e{id} format is no longer supported
443        assert!(parse_ref("e12345").is_err());
444        assert!(parse_ref("e1").is_err());
445    }
446
447    #[test]
448    fn test_parse_ref_invalid_numbers() {
449        assert!(parse_ref("cXp0f0e1").is_err());
450        assert!(parse_ref("c0pXf0e1").is_err());
451        assert!(parse_ref("c0p0fXe1").is_err());
452        assert!(parse_ref("c0p0f0eX").is_err());
453    }
454
455    #[test]
456    fn test_format_ref() {
457        assert_eq!(format_ref(0, 0, 0, 1), "c0p0f0e1");
458        assert_eq!(format_ref(1, 2, 3, 4), "c1p2f3e4");
459        assert_eq!(format_ref(12, 34, 56, 789), "c12p34f56e789");
460    }
461
462    #[test]
463    fn test_format_and_parse_roundtrip() {
464        let original = format_ref(5, 10, 2, 100);
465        let parsed = parse_ref(&original).unwrap();
466        assert_eq!(parsed.context_index, 5);
467        assert_eq!(parsed.page_index, 10);
468        assert_eq!(parsed.frame_index, 2);
469        assert_eq!(parsed.element_counter, 100);
470    }
471
472    #[test]
473    fn test_parsed_ref_new() {
474        let parsed = ParsedRef::new(1, 2, 3, 4);
475        assert_eq!(parsed.context_index, 1);
476        assert_eq!(parsed.page_index, 2);
477        assert_eq!(parsed.frame_index, 3);
478        assert_eq!(parsed.element_counter, 4);
479    }
480
481    #[test]
482    fn test_parsed_ref_equality() {
483        let a = ParsedRef::new(1, 2, 3, 4);
484        let b = ParsedRef::new(1, 2, 3, 4);
485        let c = ParsedRef::new(1, 2, 3, 5);
486        assert_eq!(a, b);
487        assert_ne!(a, c);
488    }
489}