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}