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;