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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17#[serde(rename_all = "camelCase")]
18#[derive(Default)]
19pub struct AriaSnapshot {
20    /// The ARIA role of the element.
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub role: Option<String>,
23    /// The accessible name.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub name: Option<String>,
26    /// The accessible description.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub description: Option<String>,
29    /// Whether the element is disabled.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub disabled: Option<bool>,
32    /// Whether the element is expanded (for expandable elements).
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub expanded: Option<bool>,
35    /// Whether the element is selected.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub selected: Option<bool>,
38    /// Whether the element is checked (for checkboxes/radios).
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub checked: Option<AriaCheckedState>,
41    /// Whether the element is pressed (for toggle buttons).
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub pressed: Option<bool>,
44    /// The level (for headings).
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub level: Option<u32>,
47    /// The value (for sliders, progress bars, etc.).
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub value_now: Option<f64>,
50    /// The minimum value.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub value_min: Option<f64>,
53    /// The maximum value.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub value_max: Option<f64>,
56    /// The value text.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub value_text: Option<String>,
59    /// Child elements.
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub children: Vec<AriaSnapshot>,
62}
63
64/// ARIA checked state (supports tri-state checkboxes).
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum AriaCheckedState {
68    /// Not checked.
69    False,
70    /// Checked.
71    True,
72    /// Mixed (indeterminate).
73    Mixed,
74}
75
76
77impl AriaSnapshot {
78    /// Create a new empty ARIA snapshot.
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    /// Create an ARIA snapshot with a role.
84    pub fn with_role(role: impl Into<String>) -> Self {
85        Self {
86            role: Some(role.into()),
87            ..Self::default()
88        }
89    }
90
91    /// Set the accessible name.
92    #[must_use]
93    pub fn name(mut self, name: impl Into<String>) -> Self {
94        self.name = Some(name.into());
95        self
96    }
97
98    /// Add a child element.
99    #[must_use]
100    pub fn child(mut self, child: AriaSnapshot) -> Self {
101        self.children.push(child);
102        self
103    }
104
105    /// Convert to a YAML-like string for comparison.
106    ///
107    /// This format is similar to Playwright's aria snapshot format.
108    pub fn to_yaml(&self) -> String {
109        let mut output = String::new();
110        self.write_yaml(&mut output, 0);
111        output
112    }
113
114    fn write_yaml(&self, output: &mut String, indent: usize) {
115        let prefix = "  ".repeat(indent);
116        
117        // Write role and name on the same line
118        if let Some(ref role) = self.role {
119            output.push_str(&prefix);
120            output.push_str("- ");
121            output.push_str(role);
122            
123            if let Some(ref name) = self.name {
124                output.push_str(" \"");
125                output.push_str(&name.replace('"', "\\\""));
126                output.push('"');
127            }
128            
129            // Add relevant attributes
130            if let Some(disabled) = self.disabled {
131                if disabled {
132                    output.push_str(" [disabled]");
133                }
134            }
135            if let Some(ref checked) = self.checked {
136                match checked {
137                    AriaCheckedState::True => output.push_str(" [checked]"),
138                    AriaCheckedState::Mixed => output.push_str(" [mixed]"),
139                    AriaCheckedState::False => {}
140                }
141            }
142            if let Some(selected) = self.selected {
143                if selected {
144                    output.push_str(" [selected]");
145                }
146            }
147            if let Some(expanded) = self.expanded {
148                if expanded {
149                    output.push_str(" [expanded]");
150                }
151            }
152            if let Some(level) = self.level {
153                output.push_str(&format!(" [level={level}]"));
154            }
155            
156            output.push('\n');
157            
158            // Write children
159            for child in &self.children {
160                child.write_yaml(output, indent + 1);
161            }
162        }
163    }
164
165    /// Parse from YAML-like string.
166    ///
167    /// This supports a simplified YAML-like format for snapshot comparison.
168    pub fn from_yaml(yaml: &str) -> Result<Self, LocatorError> {
169        let mut root = AriaSnapshot::new();
170        root.role = Some("root".to_string());
171        
172        let mut stack: Vec<(usize, AriaSnapshot)> = vec![(0, root)];
173        
174        for line in yaml.lines() {
175            if line.trim().is_empty() {
176                continue;
177            }
178            
179            // Calculate indent
180            let indent = line.chars().take_while(|c| *c == ' ').count() / 2;
181            let trimmed = line.trim();
182            
183            if !trimmed.starts_with('-') {
184                continue;
185            }
186            
187            let content = trimmed[1..].trim();
188            
189            // Parse role and name
190            let (role, name, attrs) = parse_aria_line(content)?;
191            
192            let mut node = AriaSnapshot::with_role(role);
193            if let Some(n) = name {
194                node.name = Some(n);
195            }
196            
197            // Apply attributes
198            for attr in attrs {
199                match attr.as_str() {
200                    "disabled" => node.disabled = Some(true),
201                    "checked" => node.checked = Some(AriaCheckedState::True),
202                    "mixed" => node.checked = Some(AriaCheckedState::Mixed),
203                    "selected" => node.selected = Some(true),
204                    "expanded" => node.expanded = Some(true),
205                    s if s.starts_with("level=") => {
206                        if let Ok(level) = s[6..].parse() {
207                            node.level = Some(level);
208                        }
209                    }
210                    _ => {}
211                }
212            }
213            
214            // Find parent and add as child
215            while stack.len() > 1 && stack.last().is_some_and(|(i, _)| *i >= indent) {
216                let (_, child) = stack.pop().unwrap();
217                if let Some((_, parent)) = stack.last_mut() {
218                    parent.children.push(child);
219                }
220            }
221            
222            stack.push((indent, node));
223        }
224        
225        // Pop remaining items
226        while stack.len() > 1 {
227            let (_, child) = stack.pop().unwrap();
228            if let Some((_, parent)) = stack.last_mut() {
229                parent.children.push(child);
230            }
231        }
232        
233        Ok(stack.pop().map(|(_, s)| s).unwrap_or_default())
234    }
235
236    /// Check if this snapshot matches another, allowing regex patterns.
237    ///
238    /// The `expected` snapshot can contain regex patterns in name fields
239    /// when enclosed in `/pattern/` syntax.
240    pub fn matches(&self, expected: &AriaSnapshot) -> bool {
241        // Check role
242        if expected.role.is_some() && self.role != expected.role {
243            return false;
244        }
245
246        // Check name (supports regex)
247        if let Some(ref expected_name) = expected.name {
248            match &self.name {
249                Some(actual_name) => {
250                    if !matches_name(expected_name, actual_name) {
251                        return false;
252                    }
253                }
254                None => return false,
255            }
256        }
257
258        // Check other attributes
259        if expected.disabled.is_some() && self.disabled != expected.disabled {
260            return false;
261        }
262        if expected.checked.is_some() && self.checked != expected.checked {
263            return false;
264        }
265        if expected.selected.is_some() && self.selected != expected.selected {
266            return false;
267        }
268        if expected.expanded.is_some() && self.expanded != expected.expanded {
269            return false;
270        }
271        if expected.level.is_some() && self.level != expected.level {
272            return false;
273        }
274
275        // Check children (order matters)
276        if expected.children.len() > self.children.len() {
277            return false;
278        }
279
280        for (i, expected_child) in expected.children.iter().enumerate() {
281            if !self.children.get(i).is_some_and(|c| c.matches(expected_child)) {
282                return false;
283            }
284        }
285
286        true
287    }
288
289    /// Generate a diff between this snapshot and expected.
290    pub fn diff(&self, expected: &AriaSnapshot) -> String {
291        let actual_yaml = self.to_yaml();
292        let expected_yaml = expected.to_yaml();
293
294        if actual_yaml == expected_yaml {
295            return String::new();
296        }
297
298        let mut diff = String::new();
299        diff.push_str("Expected:\n");
300        for line in expected_yaml.lines() {
301            diff.push_str("  ");
302            diff.push_str(line);
303            diff.push('\n');
304        }
305        diff.push_str("\nActual:\n");
306        for line in actual_yaml.lines() {
307            diff.push_str("  ");
308            diff.push_str(line);
309            diff.push('\n');
310        }
311
312        diff
313    }
314}
315
316impl fmt::Display for AriaSnapshot {
317    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318        write!(f, "{}", self.to_yaml())
319    }
320}
321
322/// Parse an aria line into role, optional name, and attributes.
323fn parse_aria_line(content: &str) -> Result<(String, Option<String>, Vec<String>), LocatorError> {
324    let mut parts = content.splitn(2, ' ');
325    let role = parts.next().unwrap_or("").to_string();
326    
327    if role.is_empty() {
328        return Err(LocatorError::EvaluationError("Empty role in aria snapshot".to_string()));
329    }
330    
331    let rest = parts.next().unwrap_or("");
332    let mut name = None;
333    let mut attrs = Vec::new();
334    
335    // Parse name (quoted string)
336    if let Some(start) = rest.find('"') {
337        if let Some(end) = rest[start + 1..].find('"') {
338            name = Some(rest[start + 1..start + 1 + end].replace("\\\"", "\""));
339        }
340    }
341    
342    // Parse attributes [attr] or [attr=value]
343    for part in rest.split('[') {
344        if let Some(end) = part.find(']') {
345            attrs.push(part[..end].to_string());
346        }
347    }
348    
349    Ok((role, name, attrs))
350}
351
352/// Check if a name matches (supports regex patterns).
353fn matches_name(pattern: &str, actual: &str) -> bool {
354    // Check for regex pattern /.../ or /.../i
355    if pattern.starts_with('/') {
356        let flags_end = pattern.rfind('/');
357        if let Some(end) = flags_end {
358            if end > 0 {
359                let regex_str = &pattern[1..end];
360                let flags = &pattern[end + 1..];
361                let case_insensitive = flags.contains('i');
362                
363                let regex_result = if case_insensitive {
364                    regex::RegexBuilder::new(regex_str)
365                        .case_insensitive(true)
366                        .build()
367                } else {
368                    regex::Regex::new(regex_str)
369                };
370                
371                if let Ok(re) = regex_result {
372                    return re.is_match(actual);
373                }
374            }
375        }
376    }
377    
378    // Exact match
379    pattern == actual
380}
381
382// Re-export the JavaScript code from the separate module
383pub use super::aria_js::aria_snapshot_js;
384
385#[cfg(test)]
386mod tests;