viewpoint_core/page/locator/aria/
mod.rs

1//! ARIA accessibility snapshot functionality.
2//!
3//! This module provides the ability to capture and compare ARIA accessibility
4//! snapshots for accessibility testing.
5
6use std::fmt;
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::LocatorError;
11
12/// An ARIA accessibility snapshot of an element or subtree.
13///
14/// The snapshot represents the accessible structure as it would be
15/// exposed to assistive technologies.
16///
17/// # Frame Boundary Support
18///
19/// For MCP (Model Context Protocol) servers and multi-frame accessibility testing,
20/// this struct supports frame boundaries:
21///
22/// - `is_frame`: Indicates this node represents an iframe/frame boundary
23/// - `frame_url`: The src URL of the iframe
24/// - `frame_name`: The name attribute of the iframe
25/// - `iframe_refs`: Collected at root level, lists all iframe ref IDs for enumeration
26///
27/// Frame boundaries are rendered in YAML as `[frame-boundary]` markers.
28///
29/// # Example with Frame Boundaries
30///
31/// ```
32/// use viewpoint_core::AriaSnapshot;
33///
34/// let mut snapshot = AriaSnapshot::with_role("iframe");
35/// snapshot.is_frame = Some(true);
36/// snapshot.frame_url = Some("https://example.com/widget".to_string());
37/// snapshot.frame_name = Some("payment-frame".to_string());
38///
39/// let yaml = snapshot.to_yaml();
40/// assert!(yaml.contains("[frame-boundary]"));
41/// ```
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43#[serde(rename_all = "camelCase")]
44#[derive(Default)]
45pub struct AriaSnapshot {
46    /// The ARIA role of the element.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub role: Option<String>,
49    /// The accessible name.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub name: Option<String>,
52    /// The accessible description.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub description: Option<String>,
55    /// Whether the element is disabled.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub disabled: Option<bool>,
58    /// Whether the element is expanded (for expandable elements).
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub expanded: Option<bool>,
61    /// Whether the element is selected.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub selected: Option<bool>,
64    /// Whether the element is checked (for checkboxes/radios).
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub checked: Option<AriaCheckedState>,
67    /// Whether the element is pressed (for toggle buttons).
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub pressed: Option<bool>,
70    /// The level (for headings).
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub level: Option<u32>,
73    /// The value (for sliders, progress bars, etc.).
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub value_now: Option<f64>,
76    /// The minimum value.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub value_min: Option<f64>,
79    /// The maximum value.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub value_max: Option<f64>,
82    /// The value text.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub value_text: Option<String>,
85    /// Whether this node represents a frame boundary (iframe/frame element).
86    ///
87    /// When true, this node marks an iframe that may contain content from
88    /// a separate frame context. Use `frame_url` and `frame_name` to identify
89    /// the frame for separate content retrieval.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub is_frame: Option<bool>,
92    /// The URL of the iframe (from src attribute).
93    ///
94    /// Only present when `is_frame` is true.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub frame_url: Option<String>,
97    /// The name attribute of the iframe.
98    ///
99    /// Only present when `is_frame` is true. Can be used to identify
100    /// the frame for navigation or content retrieval.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub frame_name: Option<String>,
103    /// Collected iframe reference IDs at the root level.
104    ///
105    /// This field is only populated at the root of a snapshot tree.
106    /// It contains unique identifiers for all iframes found during traversal,
107    /// enabling MCP servers to enumerate frames for separate content retrieval.
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub iframe_refs: Vec<String>,
110    /// Child elements.
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub children: Vec<AriaSnapshot>,
113}
114
115/// ARIA checked state (supports tri-state checkboxes).
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "lowercase")]
118pub enum AriaCheckedState {
119    /// Not checked.
120    False,
121    /// Checked.
122    True,
123    /// Mixed (indeterminate).
124    Mixed,
125}
126
127impl AriaSnapshot {
128    /// Create a new empty ARIA snapshot.
129    pub fn new() -> Self {
130        Self::default()
131    }
132
133    /// Create an ARIA snapshot with a role.
134    pub fn with_role(role: impl Into<String>) -> Self {
135        Self {
136            role: Some(role.into()),
137            ..Self::default()
138        }
139    }
140
141    /// Set the accessible name.
142    #[must_use]
143    pub fn name(mut self, name: impl Into<String>) -> Self {
144        self.name = Some(name.into());
145        self
146    }
147
148    /// Add a child element.
149    #[must_use]
150    pub fn child(mut self, child: AriaSnapshot) -> Self {
151        self.children.push(child);
152        self
153    }
154
155    /// Convert to a YAML-like string for comparison.
156    ///
157    /// This format is similar to Playwright's aria snapshot format.
158    pub fn to_yaml(&self) -> String {
159        let mut output = String::new();
160        self.write_yaml(&mut output, 0);
161        output
162    }
163
164    fn write_yaml(&self, output: &mut String, indent: usize) {
165        let prefix = "  ".repeat(indent);
166
167        // Write role and name on the same line
168        if let Some(ref role) = self.role {
169            output.push_str(&prefix);
170            output.push_str("- ");
171            output.push_str(role);
172
173            if let Some(ref name) = self.name {
174                output.push_str(" \"");
175                output.push_str(&name.replace('"', "\\\""));
176                output.push('"');
177            }
178
179            // Add relevant attributes
180            if let Some(disabled) = self.disabled {
181                if disabled {
182                    output.push_str(" [disabled]");
183                }
184            }
185            if let Some(ref checked) = self.checked {
186                match checked {
187                    AriaCheckedState::True => output.push_str(" [checked]"),
188                    AriaCheckedState::Mixed => output.push_str(" [mixed]"),
189                    AriaCheckedState::False => {}
190                }
191            }
192            if let Some(selected) = self.selected {
193                if selected {
194                    output.push_str(" [selected]");
195                }
196            }
197            if let Some(expanded) = self.expanded {
198                if expanded {
199                    output.push_str(" [expanded]");
200                }
201            }
202            if let Some(level) = self.level {
203                output.push_str(&format!(" [level={level}]"));
204            }
205
206            // Add frame boundary marker
207            if self.is_frame == Some(true) {
208                output.push_str(" [frame-boundary]");
209                // Include frame URL if available
210                if let Some(ref url) = self.frame_url {
211                    output.push_str(&format!(" [frame-url=\"{}\"]", url.replace('"', "\\\"")));
212                }
213                // Include frame name if available
214                if let Some(ref name) = self.frame_name {
215                    if !name.is_empty() {
216                        output.push_str(&format!(" [frame-name=\"{}\"]", name.replace('"', "\\\"")));
217                    }
218                }
219            }
220
221            output.push('\n');
222
223            // Write children
224            for child in &self.children {
225                child.write_yaml(output, indent + 1);
226            }
227        }
228    }
229
230    /// Parse from YAML-like string.
231    ///
232    /// This supports a simplified YAML-like format for snapshot comparison.
233    pub fn from_yaml(yaml: &str) -> Result<Self, LocatorError> {
234        let mut root = AriaSnapshot::new();
235        root.role = Some("root".to_string());
236
237        let mut stack: Vec<(usize, AriaSnapshot)> = vec![(0, root)];
238
239        for line in yaml.lines() {
240            if line.trim().is_empty() {
241                continue;
242            }
243
244            // Calculate indent
245            let indent = line.chars().take_while(|c| *c == ' ').count() / 2;
246            let trimmed = line.trim();
247
248            if !trimmed.starts_with('-') {
249                continue;
250            }
251
252            let content = trimmed[1..].trim();
253
254            // Parse role and name
255            let (role, name, attrs) = parse_aria_line(content)?;
256
257            let mut node = AriaSnapshot::with_role(role);
258            if let Some(n) = name {
259                node.name = Some(n);
260            }
261
262            // Apply attributes
263            for attr in attrs {
264                match attr.as_str() {
265                    "disabled" => node.disabled = Some(true),
266                    "checked" => node.checked = Some(AriaCheckedState::True),
267                    "mixed" => node.checked = Some(AriaCheckedState::Mixed),
268                    "selected" => node.selected = Some(true),
269                    "expanded" => node.expanded = Some(true),
270                    "frame-boundary" => node.is_frame = Some(true),
271                    s if s.starts_with("level=") => {
272                        if let Ok(level) = s[6..].parse() {
273                            node.level = Some(level);
274                        }
275                    }
276                    s if s.starts_with("frame-url=\"") && s.ends_with('"') => {
277                        // Parse frame-url="value"
278                        let url = &s[11..s.len() - 1];
279                        node.frame_url = Some(url.replace("\\\"", "\""));
280                    }
281                    s if s.starts_with("frame-name=\"") && s.ends_with('"') => {
282                        // Parse frame-name="value"
283                        let name = &s[12..s.len() - 1];
284                        node.frame_name = Some(name.replace("\\\"", "\""));
285                    }
286                    _ => {}
287                }
288            }
289
290            // Find parent and add as child
291            while stack.len() > 1 && stack.last().is_some_and(|(i, _)| *i >= indent) {
292                let (_, child) = stack.pop().unwrap();
293                if let Some((_, parent)) = stack.last_mut() {
294                    parent.children.push(child);
295                }
296            }
297
298            stack.push((indent, node));
299        }
300
301        // Pop remaining items
302        while stack.len() > 1 {
303            let (_, child) = stack.pop().unwrap();
304            if let Some((_, parent)) = stack.last_mut() {
305                parent.children.push(child);
306            }
307        }
308
309        Ok(stack.pop().map(|(_, s)| s).unwrap_or_default())
310    }
311
312    /// Check if this snapshot matches another, allowing regex patterns.
313    ///
314    /// The `expected` snapshot can contain regex patterns in name fields
315    /// when enclosed in `/pattern/` syntax.
316    pub fn matches(&self, expected: &AriaSnapshot) -> bool {
317        // Check role
318        if expected.role.is_some() && self.role != expected.role {
319            return false;
320        }
321
322        // Check name (supports regex)
323        if let Some(ref expected_name) = expected.name {
324            match &self.name {
325                Some(actual_name) => {
326                    if !matches_name(expected_name, actual_name) {
327                        return false;
328                    }
329                }
330                None => return false,
331            }
332        }
333
334        // Check other attributes
335        if expected.disabled.is_some() && self.disabled != expected.disabled {
336            return false;
337        }
338        if expected.checked.is_some() && self.checked != expected.checked {
339            return false;
340        }
341        if expected.selected.is_some() && self.selected != expected.selected {
342            return false;
343        }
344        if expected.expanded.is_some() && self.expanded != expected.expanded {
345            return false;
346        }
347        if expected.level.is_some() && self.level != expected.level {
348            return false;
349        }
350
351        // Check children (order matters)
352        if expected.children.len() > self.children.len() {
353            return false;
354        }
355
356        for (i, expected_child) in expected.children.iter().enumerate() {
357            if !self
358                .children
359                .get(i)
360                .is_some_and(|c| c.matches(expected_child))
361            {
362                return false;
363            }
364        }
365
366        true
367    }
368
369    /// Generate a diff between this snapshot and expected.
370    pub fn diff(&self, expected: &AriaSnapshot) -> String {
371        let actual_yaml = self.to_yaml();
372        let expected_yaml = expected.to_yaml();
373
374        if actual_yaml == expected_yaml {
375            return String::new();
376        }
377
378        let mut diff = String::new();
379        diff.push_str("Expected:\n");
380        for line in expected_yaml.lines() {
381            diff.push_str("  ");
382            diff.push_str(line);
383            diff.push('\n');
384        }
385        diff.push_str("\nActual:\n");
386        for line in actual_yaml.lines() {
387            diff.push_str("  ");
388            diff.push_str(line);
389            diff.push('\n');
390        }
391
392        diff
393    }
394}
395
396impl fmt::Display for AriaSnapshot {
397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398        write!(f, "{}", self.to_yaml())
399    }
400}
401
402/// Parse an aria line into role, optional name, and attributes.
403fn parse_aria_line(content: &str) -> Result<(String, Option<String>, Vec<String>), LocatorError> {
404    let mut parts = content.splitn(2, ' ');
405    let role = parts.next().unwrap_or("").to_string();
406
407    if role.is_empty() {
408        return Err(LocatorError::EvaluationError(
409            "Empty role in aria snapshot".to_string(),
410        ));
411    }
412
413    let rest = parts.next().unwrap_or("");
414    let mut name = None;
415    let mut attrs = Vec::new();
416
417    // Parse name (quoted string)
418    if let Some(start) = rest.find('"') {
419        if let Some(end) = rest[start + 1..].find('"') {
420            name = Some(rest[start + 1..start + 1 + end].replace("\\\"", "\""));
421        }
422    }
423
424    // Parse attributes [attr] or [attr=value]
425    for part in rest.split('[') {
426        if let Some(end) = part.find(']') {
427            attrs.push(part[..end].to_string());
428        }
429    }
430
431    Ok((role, name, attrs))
432}
433
434/// Check if a name matches (supports regex patterns).
435fn matches_name(pattern: &str, actual: &str) -> bool {
436    // Check for regex pattern /.../ or /.../i
437    if pattern.starts_with('/') {
438        let flags_end = pattern.rfind('/');
439        if let Some(end) = flags_end {
440            if end > 0 {
441                let regex_str = &pattern[1..end];
442                let flags = &pattern[end + 1..];
443                let case_insensitive = flags.contains('i');
444
445                let regex_result = if case_insensitive {
446                    regex::RegexBuilder::new(regex_str)
447                        .case_insensitive(true)
448                        .build()
449                } else {
450                    regex::Regex::new(regex_str)
451                };
452
453                if let Ok(re) = regex_result {
454                    return re.is_match(actual);
455                }
456            }
457        }
458    }
459
460    // Exact match
461    pattern == actual
462}
463
464// Re-export the JavaScript code from the separate module
465pub use super::aria_js::aria_snapshot_js;
466
467#[cfg(test)]
468mod tests;