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)]
43#[serde(rename_all = "camelCase")]
44#[derive(Default)]
45pub struct AriaSnapshot {
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub role: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub name: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub description: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub disabled: Option<bool>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub expanded: Option<bool>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub selected: Option<bool>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub checked: Option<AriaCheckedState>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub pressed: Option<bool>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub level: Option<u32>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub value_now: Option<f64>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub value_min: Option<f64>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub value_max: Option<f64>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub value_text: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
91 pub is_frame: Option<bool>,
92 #[serde(skip_serializing_if = "Option::is_none")]
96 pub frame_url: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
102 pub frame_name: Option<String>,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
109 pub iframe_refs: Vec<String>,
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
112 pub children: Vec<AriaSnapshot>,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "lowercase")]
118pub enum AriaCheckedState {
119 False,
121 True,
123 Mixed,
125}
126
127impl AriaSnapshot {
128 pub fn new() -> Self {
130 Self::default()
131 }
132
133 pub fn with_role(role: impl Into<String>) -> Self {
135 Self {
136 role: Some(role.into()),
137 ..Self::default()
138 }
139 }
140
141 #[must_use]
143 pub fn name(mut self, name: impl Into<String>) -> Self {
144 self.name = Some(name.into());
145 self
146 }
147
148 #[must_use]
150 pub fn child(mut self, child: AriaSnapshot) -> Self {
151 self.children.push(child);
152 self
153 }
154
155 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 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 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 if self.is_frame == Some(true) {
208 output.push_str(" [frame-boundary]");
209 if let Some(ref url) = self.frame_url {
211 output.push_str(&format!(" [frame-url=\"{}\"]", url.replace('"', "\\\"")));
212 }
213 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 for child in &self.children {
225 child.write_yaml(output, indent + 1);
226 }
227 }
228 }
229
230 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 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 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 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 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 let name = &s[12..s.len() - 1];
284 node.frame_name = Some(name.replace("\\\"", "\""));
285 }
286 _ => {}
287 }
288 }
289
290 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 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 pub fn matches(&self, expected: &AriaSnapshot) -> bool {
317 if expected.role.is_some() && self.role != expected.role {
319 return false;
320 }
321
322 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 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 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 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
402fn 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 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 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
434fn matches_name(pattern: &str, actual: &str) -> bool {
436 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 pattern == actual
462}
463
464pub use super::aria_js::aria_snapshot_js;
466
467#[cfg(test)]
468mod tests;