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 `e{backendNodeId}` where `backendNodeId`
9//! is the CDP backend node identifier. This format:
10//!
11//! - Is short and readable
12//! - Uses the `e` prefix to distinguish from frame refs (`frame-0`)
13//! - Maps directly to CDP `backendNodeId` for efficient resolution
14//!
15//! # MCP Server Usage
16//!
17//! This feature is designed for MCP (Model Context Protocol) servers that need to:
18//!
19//! 1. Present an accessibility tree to AI/users
20//! 2. Allow interaction with any element in that tree
21//!
22//! Without refs, users would need to re-query elements by role/name, which is fragile
23//! when multiple elements share the same accessible properties.
24//!
25//! # Example: Click a Button by Ref
26//!
27//! ```no_run
28//! use viewpoint_core::Page;
29//!
30//! # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
31//! // Capture snapshot with refs
32//! let snapshot = page.aria_snapshot().await?;
33//!
34//! // Find a button's ref in the snapshot
35//! if let Some(ref node_ref) = snapshot.node_ref.as_ref() {
36//! // Resolve ref to element handle (for low-level operations)
37//! let handle = page.element_from_ref(node_ref).await?;
38//!
39//! // Or get a locator for auto-waiting behavior (preferred)
40//! let locator = page.locator_from_ref(node_ref);
41//! locator.click().await?;
42//! }
43//! # Ok(())
44//! # }
45//! ```
46//!
47//! # Example: Find and Interact with Snapshot Elements
48//!
49//! ```no_run
50//! use viewpoint_core::{Page, AriaSnapshot};
51//!
52//! # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
53//! // Capture snapshot
54//! let snapshot = page.aria_snapshot().await?;
55//!
56//! // Helper to find a button by name
57//! fn find_button_ref(snapshot: &AriaSnapshot, name: &str) -> Option<String> {
58//! if snapshot.role.as_deref() == Some("button")
59//! && snapshot.name.as_deref() == Some(name)
60//! {
61//! return snapshot.node_ref.clone();
62//! }
63//! for child in &snapshot.children {
64//! if let Some(r) = find_button_ref(child, name) {
65//! return Some(r);
66//! }
67//! }
68//! None
69//! }
70//!
71//! // Find "Submit" button and click it
72//! if let Some(submit_ref) = find_button_ref(&snapshot, "Submit") {
73//! page.locator_from_ref(&submit_ref).click().await?;
74//! }
75//! # Ok(())
76//! # }
77//! ```
78
79use tracing::{debug, instrument};
80use viewpoint_cdp::protocol::dom::{BackendNodeId, DescribeNodeParams, DescribeNodeResult, ResolveNodeParams, ResolveNodeResult};
81
82use super::locator::ElementHandle;
83use super::Page;
84use crate::error::{LocatorError, PageError};
85
86/// Parse a ref string to extract the backend node ID.
87///
88/// Refs are formatted as `e{backendNodeId}`, e.g., `e12345`.
89///
90/// # Errors
91///
92/// Returns `LocatorError::EvaluationError` if the ref format is invalid.
93pub fn parse_ref(ref_str: &str) -> Result<BackendNodeId, LocatorError> {
94 if !ref_str.starts_with('e') {
95 return Err(LocatorError::EvaluationError(format!(
96 "Invalid ref format: expected 'e{{backendNodeId}}', got '{ref_str}'"
97 )));
98 }
99
100 ref_str[1..]
101 .parse::<BackendNodeId>()
102 .map_err(|e| LocatorError::EvaluationError(format!("Invalid backend node ID in ref: {e}")))
103}
104
105/// Format a backend node ID as a ref string.
106pub fn format_ref(backend_node_id: BackendNodeId) -> String {
107 format!("e{backend_node_id}")
108}
109
110impl Page {
111 /// Get an element handle from a snapshot ref.
112 ///
113 /// This resolves the ref (format: `e{backendNodeId}`) to an `ElementHandle`
114 /// that can be used for low-level DOM operations.
115 ///
116 /// # Arguments
117 ///
118 /// * `ref_str` - The element ref from an ARIA snapshot (e.g., `e12345`)
119 ///
120 /// # Example
121 ///
122 /// ```no_run
123 /// use viewpoint_core::Page;
124 ///
125 /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
126 /// let snapshot = page.aria_snapshot().await?;
127 /// // Assume we found a button with ref "e12345"
128 /// let handle = page.element_from_ref("e12345").await?;
129 /// let text: String = handle.evaluate("this.textContent").await?;
130 /// # Ok(())
131 /// # }
132 /// ```
133 ///
134 /// # Errors
135 ///
136 /// Returns an error if:
137 /// - The page is closed
138 /// - The ref format is invalid
139 /// - The element no longer exists (stale ref)
140 #[instrument(level = "debug", skip(self), fields(target_id = %self.target_id, ref_str = %ref_str))]
141 pub async fn element_from_ref(&self, ref_str: &str) -> Result<ElementHandle<'_>, LocatorError> {
142 if self.is_closed() {
143 return Err(LocatorError::PageClosed);
144 }
145
146 let backend_node_id = parse_ref(ref_str)?;
147 debug!(backend_node_id = backend_node_id, "Resolving ref to element");
148
149 // Use DOM.resolveNode to get a RemoteObject from the backend node ID
150 let result: ResolveNodeResult = self
151 .connection()
152 .send_command(
153 "DOM.resolveNode",
154 Some(ResolveNodeParams {
155 node_id: None,
156 backend_node_id: Some(backend_node_id),
157 object_group: Some("viewpoint-ref".to_string()),
158 execution_context_id: None,
159 }),
160 Some(self.session_id()),
161 )
162 .await
163 .map_err(|e| {
164 LocatorError::NotFound(format!(
165 "Failed to resolve ref '{ref_str}': element may no longer exist. Error: {e}"
166 ))
167 })?;
168
169 let object_id = result.object.object_id.ok_or_else(|| {
170 LocatorError::NotFound(format!(
171 "Failed to get object ID for ref '{ref_str}': element may be detached"
172 ))
173 })?;
174
175 debug!(object_id = %object_id, "Resolved ref to element handle");
176
177 Ok(ElementHandle {
178 object_id,
179 page: self,
180 })
181 }
182
183 /// Create a locator from a snapshot ref.
184 ///
185 /// This creates a `Locator` that targets the element identified by the ref.
186 /// Unlike `element_from_ref`, the locator provides auto-waiting behavior
187 /// and is the preferred way to interact with elements.
188 ///
189 /// # Arguments
190 ///
191 /// * `ref_str` - The element ref from an ARIA snapshot (e.g., `e12345`)
192 ///
193 /// # Example
194 ///
195 /// ```no_run
196 /// use viewpoint_core::Page;
197 ///
198 /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
199 /// let snapshot = page.aria_snapshot().await?;
200 /// // Assume we found a button with ref "e12345"
201 /// let locator = page.locator_from_ref("e12345");
202 /// locator.click().await?;
203 /// # Ok(())
204 /// # }
205 /// ```
206 ///
207 /// # Panics
208 ///
209 /// Panics if the ref format is invalid. Use `element_from_ref` if you need
210 /// to handle invalid refs gracefully.
211 pub fn locator_from_ref(&self, ref_str: &str) -> super::Locator<'_> {
212 use super::locator::{Locator, Selector};
213
214 // Parse the ref to validate format
215 let backend_node_id = parse_ref(ref_str)
216 .expect("Invalid ref format. Refs must be in format 'e{backendNodeId}'");
217
218 // Create a locator with a backend node ID selector
219 Locator::new(self, Selector::BackendNodeId(backend_node_id))
220 }
221
222 /// Get the backend node ID for an element from its object ID.
223 ///
224 /// This is a lower-level method used internally during snapshot capture
225 /// to resolve element references.
226 pub(crate) async fn get_backend_node_id(
227 &self,
228 object_id: &str,
229 ) -> Result<BackendNodeId, PageError> {
230 let result: DescribeNodeResult = self
231 .connection()
232 .send_command(
233 "DOM.describeNode",
234 Some(DescribeNodeParams {
235 node_id: None,
236 backend_node_id: None,
237 object_id: Some(object_id.to_string()),
238 depth: Some(0),
239 pierce: None,
240 }),
241 Some(self.session_id()),
242 )
243 .await?;
244
245 Ok(result.node.backend_node_id)
246 }
247}