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(
111        context_index: usize,
112        page_index: usize,
113        frame_index: usize,
114        element_counter: usize,
115    ) -> Self {
116        Self {
117            context_index,
118            page_index,
119            frame_index,
120            element_counter,
121        }
122    }
123}
124
125/// Parse a ref string to extract context, page, frame, and element indices.
126///
127/// Refs are formatted as `c{contextIndex}p{pageIndex}f{frameIndex}e{counter}`, e.g., `c0p0f0e1`.
128///
129/// # Errors
130///
131/// Returns `LocatorError::EvaluationError` if the ref format is invalid.
132pub fn parse_ref(ref_str: &str) -> Result<ParsedRef, LocatorError> {
133    if !ref_str.starts_with('c') {
134        return Err(LocatorError::EvaluationError(format!(
135            "Invalid ref format: expected 'c{{ctx}}p{{page}}f{{frame}}e{{counter}}', got '{ref_str}'"
136        )));
137    }
138
139    parse_ref_format(ref_str)
140}
141
142/// Parse the ref format: c{contextIndex}p{pageIndex}f{frameIndex}e{counter}
143fn parse_ref_format(ref_str: &str) -> Result<ParsedRef, LocatorError> {
144    // Format: c0p0f0e1
145    let without_c = ref_str.strip_prefix('c').ok_or_else(|| {
146        LocatorError::EvaluationError(format!(
147            "Invalid ref format: expected 'c' prefix in '{ref_str}'"
148        ))
149    })?;
150
151    let (context_part, rest) = without_c.split_once('p').ok_or_else(|| {
152        LocatorError::EvaluationError(format!(
153            "Invalid ref format: expected 'p' separator in '{ref_str}'"
154        ))
155    })?;
156
157    let (page_part, rest) = rest.split_once('f').ok_or_else(|| {
158        LocatorError::EvaluationError(format!(
159            "Invalid ref format: expected 'f' separator in '{ref_str}'"
160        ))
161    })?;
162
163    let (frame_part, element_part) = rest.split_once('e').ok_or_else(|| {
164        LocatorError::EvaluationError(format!(
165            "Invalid ref format: expected 'e' separator in '{ref_str}'"
166        ))
167    })?;
168
169    let context_index = context_part.parse::<usize>().map_err(|e| {
170        LocatorError::EvaluationError(format!("Invalid context index in ref '{ref_str}': {e}"))
171    })?;
172
173    let page_index = page_part.parse::<usize>().map_err(|e| {
174        LocatorError::EvaluationError(format!("Invalid page index in ref '{ref_str}': {e}"))
175    })?;
176
177    let frame_index = frame_part.parse::<usize>().map_err(|e| {
178        LocatorError::EvaluationError(format!("Invalid frame index in ref '{ref_str}': {e}"))
179    })?;
180
181    let element_counter = element_part.parse::<usize>().map_err(|e| {
182        LocatorError::EvaluationError(format!("Invalid element counter in ref '{ref_str}': {e}"))
183    })?;
184
185    Ok(ParsedRef::new(
186        context_index,
187        page_index,
188        frame_index,
189        element_counter,
190    ))
191}
192
193/// Format a ref string from context, page, frame, and element indices.
194///
195/// Produces the format `c{contextIndex}p{pageIndex}f{frameIndex}e{counter}`.
196pub fn format_ref(
197    context_index: usize,
198    page_index: usize,
199    frame_index: usize,
200    element_counter: usize,
201) -> String {
202    format!("c{context_index}p{page_index}f{frame_index}e{element_counter}")
203}
204
205impl Page {
206    /// Get an element handle from a snapshot ref.
207    ///
208    /// This resolves the ref (format: `c{contextIndex}p{pageIndex}f{frameIndex}e{counter}`)
209    /// to an `ElementHandle` that can be used for low-level DOM operations.
210    ///
211    /// # Arguments
212    ///
213    /// * `ref_str` - The element ref from an ARIA snapshot (e.g., `c0p0f0e1`)
214    ///
215    /// # Example
216    ///
217    /// ```no_run
218    /// use viewpoint_core::Page;
219    ///
220    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
221    /// let snapshot = page.aria_snapshot().await?;
222    /// // Assume we found a button with ref "c0p0f0e1"
223    /// let handle = page.element_from_ref("c0p0f0e1").await?;
224    /// let text: String = handle.evaluate("this.textContent").await?;
225    /// # Ok(())
226    /// # }
227    /// ```
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if:
232    /// - The page is closed
233    /// - The ref format is invalid
234    /// - The ref is from a different context or page
235    /// - The element no longer exists (stale ref)
236    #[instrument(level = "debug", skip(self), fields(target_id = %self.target_id, ref_str = %ref_str))]
237    pub async fn element_from_ref(&self, ref_str: &str) -> Result<ElementHandle<'_>, LocatorError> {
238        if self.is_closed() {
239            return Err(LocatorError::PageClosed);
240        }
241
242        let parsed = parse_ref(ref_str)?;
243
244        // Validate context index
245        if parsed.context_index != self.context_index {
246            return Err(LocatorError::EvaluationError(format!(
247                "Context index mismatch: ref '{ref_str}' is for context {}, but this page is in context {}",
248                parsed.context_index, self.context_index
249            )));
250        }
251
252        // Validate page index
253        if parsed.page_index != self.page_index {
254            return Err(LocatorError::EvaluationError(format!(
255                "Page index mismatch: ref '{ref_str}' is for page {}, but this is page {}",
256                parsed.page_index, self.page_index
257            )));
258        }
259
260        debug!(
261            context_index = parsed.context_index,
262            page_index = parsed.page_index,
263            frame_index = parsed.frame_index,
264            element_counter = parsed.element_counter,
265            "Resolving ref to element"
266        );
267
268        // Look up the backendNodeId from the ref map
269        let backend_node_id = self.get_backend_node_id_for_ref(ref_str)?;
270
271        // Use DOM.resolveNode to get a RemoteObject from the backend node ID
272        let result: ResolveNodeResult = self
273            .connection()
274            .send_command(
275                "DOM.resolveNode",
276                Some(ResolveNodeParams {
277                    node_id: None,
278                    backend_node_id: Some(backend_node_id),
279                    object_group: Some("viewpoint-ref".to_string()),
280                    execution_context_id: None,
281                }),
282                Some(self.session_id()),
283            )
284            .await
285            .map_err(|e| {
286                LocatorError::NotFound(format!("Ref not found. Capture a new snapshot. Error: {e}"))
287            })?;
288
289        let object_id = result.object.object_id.ok_or_else(|| {
290            LocatorError::NotFound("Ref not found. Capture a new snapshot.".to_string())
291        })?;
292
293        debug!(object_id = %object_id, "Resolved ref to element handle");
294
295        Ok(ElementHandle {
296            object_id,
297            page: self,
298        })
299    }
300
301    /// Create a locator from a snapshot ref.
302    ///
303    /// This creates a `Locator` that targets the element identified by the ref.
304    /// Unlike `element_from_ref`, the locator provides auto-waiting behavior
305    /// and is the preferred way to interact with elements.
306    ///
307    /// # Arguments
308    ///
309    /// * `ref_str` - The element ref from an ARIA snapshot (e.g., `c0p0f0e1`)
310    ///
311    /// # Example
312    ///
313    /// ```no_run
314    /// use viewpoint_core::Page;
315    ///
316    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
317    /// let snapshot = page.aria_snapshot().await?;
318    /// // Assume we found a button with ref "c0p0f0e1"
319    /// let locator = page.locator_from_ref("c0p0f0e1");
320    /// locator.click().await?;
321    /// # Ok(())
322    /// # }
323    /// ```
324    ///
325    /// # Panics
326    ///
327    /// Panics if the ref format is invalid. Use `element_from_ref` if you need
328    /// to handle invalid refs gracefully.
329    pub fn locator_from_ref(&self, ref_str: &str) -> super::Locator<'_> {
330        use super::locator::{Locator, Selector};
331
332        // Parse the ref to validate format and indices
333        let parsed = parse_ref(ref_str)
334            .expect("Invalid ref format. Refs must be in format 'c{ctx}p{page}f{frame}e{counter}'");
335
336        // Validate indices match this page
337        assert!(
338            parsed.context_index == self.context_index,
339            "Context index mismatch: ref is for context {}, but this page is in context {}",
340            parsed.context_index,
341            self.context_index
342        );
343
344        assert!(
345            parsed.page_index == self.page_index,
346            "Page index mismatch: ref is for page {}, but this is page {}",
347            parsed.page_index,
348            self.page_index
349        );
350
351        // Create a locator with a ref selector that will lookup from the ref map
352        Locator::new(self, Selector::Ref(ref_str.to_string()))
353    }
354
355    /// Get the backend node ID for an element from its object ID.
356    ///
357    /// This is a lower-level method used internally during snapshot capture
358    /// to resolve element references.
359    pub(crate) async fn get_backend_node_id(
360        &self,
361        object_id: &str,
362    ) -> Result<BackendNodeId, PageError> {
363        let result: DescribeNodeResult = self
364            .connection()
365            .send_command(
366                "DOM.describeNode",
367                Some(DescribeNodeParams {
368                    node_id: None,
369                    backend_node_id: None,
370                    object_id: Some(object_id.to_string()),
371                    depth: Some(0),
372                    pierce: None,
373                }),
374                Some(self.session_id()),
375            )
376            .await?;
377
378        Ok(result.node.backend_node_id)
379    }
380
381    /// Get the backend node ID for a ref from the ref map.
382    ///
383    /// This is used by `element_from_ref` and `locator_from_ref` to lookup
384    /// the backendNodeId for a ref captured during `aria_snapshot()`.
385    ///
386    /// # Errors
387    ///
388    /// Returns an error if the ref is not found in the ref map.
389    pub fn get_backend_node_id_for_ref(
390        &self,
391        ref_str: &str,
392    ) -> Result<BackendNodeId, LocatorError> {
393        self.ref_map.read().get(ref_str).copied().ok_or_else(|| {
394            LocatorError::NotFound("Ref not found. Capture a new snapshot.".to_string())
395        })
396    }
397
398    /// Store a ref mapping in the page's ref map.
399    ///
400    /// This is called during `aria_snapshot()` to populate the ref map
401    /// with the element refs and their corresponding backendNodeIds.
402    pub(crate) fn store_ref_mapping(&self, ref_str: String, backend_node_id: BackendNodeId) {
403        self.ref_map.write().insert(ref_str, backend_node_id);
404    }
405
406    /// Clear all ref mappings.
407    ///
408    /// This is called at the beginning of `aria_snapshot()` to clear
409    /// stale refs from a previous snapshot.
410    pub(crate) fn clear_ref_map(&self) {
411        self.ref_map.write().clear();
412    }
413}
414
415#[cfg(test)]
416mod tests;