viewpoint_core/page/locator/aria/
mod.rs1use std::fmt;
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::LocatorError;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17#[serde(rename_all = "camelCase")]
18#[derive(Default)]
19pub struct AriaSnapshot {
20 #[serde(skip_serializing_if = "Option::is_none")]
22 pub role: Option<String>,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub name: Option<String>,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub description: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub disabled: Option<bool>,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub expanded: Option<bool>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub selected: Option<bool>,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub checked: Option<AriaCheckedState>,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub pressed: Option<bool>,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub level: Option<u32>,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub value_now: Option<f64>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub value_min: Option<f64>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub value_max: Option<f64>,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub value_text: Option<String>,
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub children: Vec<AriaSnapshot>,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum AriaCheckedState {
68 False,
70 True,
72 Mixed,
74}
75
76
77impl AriaSnapshot {
78 pub fn new() -> Self {
80 Self::default()
81 }
82
83 pub fn with_role(role: impl Into<String>) -> Self {
85 Self {
86 role: Some(role.into()),
87 ..Self::default()
88 }
89 }
90
91 #[must_use]
93 pub fn name(mut self, name: impl Into<String>) -> Self {
94 self.name = Some(name.into());
95 self
96 }
97
98 #[must_use]
100 pub fn child(mut self, child: AriaSnapshot) -> Self {
101 self.children.push(child);
102 self
103 }
104
105 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 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 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 for child in &self.children {
160 child.write_yaml(output, indent + 1);
161 }
162 }
163 }
164
165 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 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 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 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 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 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 pub fn matches(&self, expected: &AriaSnapshot) -> bool {
241 if expected.role.is_some() && self.role != expected.role {
243 return false;
244 }
245
246 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 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 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 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
322fn 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 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 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
352fn matches_name(pattern: &str, actual: &str) -> bool {
354 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 pattern == actual
380}
381
382pub use super::aria_js::aria_snapshot_js;
384
385#[cfg(test)]
386mod tests;