1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5#![doc(html_root_url = "https://docs.smix.dev/smix-error")]
17
18mod redact;
19pub use redact::{MASK, mask as mask_secret};
20use serde::{Deserialize, Serialize};
21use smix_screen::{ElementSummary, ScreenFacts, WindowInfo, WindowKind};
22use smix_selector::{Selector, describe_selector};
23use std::fmt;
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
29#[non_exhaustive]
33pub enum FailureCode {
34 ElementNotFound,
36 NotVisible,
38 NotEnabled,
40 Ambiguous,
42 Timeout,
44 AssertionFailed,
46 AppNotRunning,
48 SimulatorNotBooted,
50 TapMissed,
59 CoordinateSpaceMismatch,
71 DriverError,
73 CaptureBackpressure,
84 AppNotInForeground,
88}
89
90#[derive(Clone, Debug, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct ExpectationFailure {
104 pub ok: False,
107 pub code: FailureCode,
109 pub message: String,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub selector: Option<Selector>,
114 #[serde(default)]
116 pub suggestions: Vec<String>,
117 #[serde(default)]
119 pub visible_elements: Vec<ElementSummary>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub hint: Option<String>,
123 #[serde(default)]
135 pub smix_version: String,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub screenshot: Option<String>,
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
142 pub device_log: Vec<String>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub visible_total: Option<usize>,
151 #[serde(default, skip_serializing_if = "Vec::is_empty")]
153 pub windows: Vec<WindowInfo>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub unreadable_windows: Option<u32>,
158}
159
160#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(transparent)]
164pub struct False(pub bool);
165
166#[derive(Default)]
168pub struct FailureInit {
169 pub code: Option<FailureCode>,
171 pub message: String,
173 pub selector: Option<Selector>,
175 pub suggestions: Vec<String>,
177 pub visible_elements: Vec<ElementSummary>,
179 pub hint: Option<String>,
181 pub screenshot: Option<String>,
183 pub device_log: Vec<String>,
185 pub visible_total: Option<usize>,
187 pub windows: Vec<WindowInfo>,
189 pub unreadable_windows: Option<u32>,
191}
192
193impl FailureInit {
194 #[must_use]
201 pub fn with_screen(mut self, facts: ScreenFacts) -> Self {
202 self.visible_elements = facts.elements;
203 self.visible_total = Some(facts.total);
204 self.windows = facts.windows;
205 self.unreadable_windows = (facts.unreadable > 0).then_some(facts.unreadable);
206 self
207 }
208
209 #[must_use]
216 pub fn with_screen_from(mut self, earlier: &ExpectationFailure) -> Self {
217 self.visible_elements = earlier.visible_elements.clone();
218 self.visible_total = earlier.visible_total;
219 self.windows = earlier.windows.clone();
220 self.unreadable_windows = earlier.unreadable_windows;
221 self
222 }
223}
224
225impl ExpectationFailure {
226 pub fn new(init: FailureInit) -> Self {
230 ExpectationFailure {
231 ok: False(false),
232 code: init.code.unwrap_or(FailureCode::DriverError),
233 message: init.message,
234 selector: init.selector,
235 suggestions: init.suggestions,
236 visible_elements: init.visible_elements,
237 hint: init.hint,
238 smix_version: env!("CARGO_PKG_VERSION").to_string(),
239 screenshot: init.screenshot,
240 device_log: init.device_log,
241 visible_total: init.visible_total,
242 windows: init.windows,
243 unreadable_windows: init.unreadable_windows,
244 }
245 }
246
247 #[must_use]
250 pub fn to_prompt(&self) -> String {
251 let mut lines: Vec<String> = Vec::new();
252 lines.push(format!(
253 "FAIL [{}]: {}",
254 format_code(self.code),
255 self.message
256 ));
257 if !self.smix_version.is_empty() {
262 lines.push(format!(" smix: {}", self.smix_version));
263 }
264 if let Some(sel) = &self.selector {
265 lines.push(format!(" selector: {}", describe_selector(sel)));
266 }
267 if !self.suggestions.is_empty() {
268 lines.push(" suggestions:".into());
269 for s in &self.suggestions {
270 lines.push(format!(" - {}", s));
271 }
272 }
273 if let Some(line) = on_screen_line(&self.windows, self.unreadable_windows) {
274 lines.push(line);
275 }
276 if !self.visible_elements.is_empty() {
277 let n = self.visible_elements.len().min(10);
278 lines.push(match self.visible_total {
279 Some(total) if self.windows.iter().any(|w| w.focused) => {
280 format!(" visible elements ({n} of {total}, the focused app's first):")
281 }
282 Some(total) => format!(" visible elements ({n} of {total}):"),
283 None => format!(" visible elements (top {n}):"),
284 });
285 for el in self.visible_elements.iter().take(10) {
286 lines.push(format!(" - {}", render_element(el)));
287 }
288 }
289 if let Some(h) = &self.hint {
290 lines.push(format!(" hint: {}", h));
291 }
292 if !self.device_log.is_empty() {
293 const LOG_PROMPT_CAP: usize = 200;
295 let n = self.device_log.len();
296 lines.push(format!(" device log (last {} lines):", n));
297 let start = n.saturating_sub(LOG_PROMPT_CAP);
298 for dl in &self.device_log[start..] {
299 lines.push(format!(" - {}", dl));
300 }
301 }
302 lines.join("\n")
303 }
304}
305
306impl fmt::Display for ExpectationFailure {
307 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308 write!(f, "{}", self.to_prompt())
309 }
310}
311
312impl std::error::Error for ExpectationFailure {}
313
314fn on_screen_line(windows: &[WindowInfo], unreadable: Option<u32>) -> Option<String> {
321 let mut parts: Vec<(String, usize)> = Vec::new();
322 for w in windows {
323 let who = w.package.as_deref().unwrap_or("(package not reported)");
324 let kind = match w.kind {
325 WindowKind::Application => "application",
326 WindowKind::InputMethod => "keyboard",
327 WindowKind::System => "system",
328 WindowKind::Other => "other",
329 };
330 let label = if w.focused {
331 format!("{who} ({kind}, focused)")
332 } else {
333 format!("{who} ({kind})")
334 };
335 match parts.last_mut() {
336 Some((last, n)) if *last == label => *n += 1,
337 _ => parts.push((label, 1)),
338 }
339 }
340 let mut bits: Vec<String> = parts
341 .into_iter()
342 .map(|(l, n)| if n > 1 { format!("{l} ×{n}") } else { l })
343 .collect();
344 match unreadable {
345 Some(1) => bits.push("1 window unreadable".into()),
346 Some(n) if n > 1 => bits.push(format!("{n} windows unreadable")),
347 _ => {}
348 }
349 (!bits.is_empty()).then(|| format!(" on screen: {}", bits.join(" · ")))
350}
351
352fn format_code(c: FailureCode) -> &'static str {
353 match c {
354 FailureCode::ElementNotFound => "ELEMENT_NOT_FOUND",
355 FailureCode::NotVisible => "NOT_VISIBLE",
356 FailureCode::NotEnabled => "NOT_ENABLED",
357 FailureCode::Ambiguous => "AMBIGUOUS",
358 FailureCode::Timeout => "TIMEOUT",
359 FailureCode::AssertionFailed => "ASSERTION_FAILED",
360 FailureCode::AppNotRunning => "APP_NOT_RUNNING",
361 FailureCode::SimulatorNotBooted => "SIMULATOR_NOT_BOOTED",
362 FailureCode::TapMissed => "TAP_MISSED",
363 FailureCode::CoordinateSpaceMismatch => "COORDINATE_SPACE_MISMATCH",
364 FailureCode::DriverError => "DRIVER_ERROR",
365 FailureCode::CaptureBackpressure => "CAPTURE_BACKPRESSURE",
366 FailureCode::AppNotInForeground => "APP_NOT_IN_FOREGROUND",
367 }
368}
369
370fn render_element(el: &ElementSummary) -> String {
371 let role_str = el.role.map(|r| r.as_str()).unwrap_or("unknown");
372 let mut bits: Vec<String> = vec![role_str.to_string()];
373 if let Some(n) = &el.name {
374 bits.push(format!("name={:?}", n));
375 }
376 if let Some(i) = &el.id {
377 bits.push(format!("id=\"{}\"", i));
378 }
379 if let Some(t) = &el.text
380 && Some(t) != el.name.as_ref()
381 {
382 bits.push(format!("text={:?}", t));
383 }
384 if !el.enabled {
385 bits.push("disabled".into());
386 }
387 bits.join(" ")
388}
389
390const SUGGESTION_THRESHOLD: f64 = 0.5;
393const SUGGESTION_TOP_N: usize = 3;
394
395#[must_use]
401pub fn build_suggestions(target: Option<&str>, visible: &[ElementSummary]) -> Vec<String> {
402 let Some(target) = target else {
403 return Vec::new();
404 };
405 let lower_target = target.to_lowercase();
406 let mut candidates: Vec<(f64, &'static str, String, usize)> = Vec::new();
407 for (i, el) in visible.iter().enumerate() {
408 let mut best: Option<(f64, &'static str, String)> = None;
409 if let Some(name) = &el.name
410 && !name.is_empty()
411 {
412 let s = similarity(&name.to_lowercase(), &lower_target);
413 if s > SUGGESTION_THRESHOLD {
414 best = Some((s, "name", name.clone()));
415 }
416 }
417 if let Some(id) = &el.id
418 && !id.is_empty()
419 {
420 let s = similarity(&id.to_lowercase(), &lower_target);
421 if s > SUGGESTION_THRESHOLD && best.as_ref().map(|(bs, _, _)| s > *bs).unwrap_or(true) {
422 best = Some((s, "id", id.clone()));
423 }
424 }
425 if let Some(text) = &el.text
426 && !text.is_empty()
427 {
428 let s = similarity(&text.to_lowercase(), &lower_target);
429 if s > SUGGESTION_THRESHOLD && best.as_ref().map(|(bs, _, _)| s > *bs).unwrap_or(true) {
430 best = Some((s, "text", text.clone()));
431 }
432 }
433 if let Some((score, field, value)) = best {
434 candidates.push((score, field, value, i));
435 }
436 }
437 let field_rank = |f: &str| match f {
439 "name" => 0,
440 "id" => 1,
441 _ => 2, };
443 candidates.sort_by(|a, b| {
444 b.0.partial_cmp(&a.0)
445 .unwrap_or(std::cmp::Ordering::Equal)
446 .then_with(|| field_rank(a.1).cmp(&field_rank(b.1)))
447 .then_with(|| a.3.cmp(&b.3))
448 });
449 candidates
450 .into_iter()
451 .take(SUGGESTION_TOP_N)
452 .map(|(score, field, value, _)| {
453 format!(
454 "Did you mean {:?}? (similarity {:.2}, field {})",
455 value, score, field
456 )
457 })
458 .collect()
459}
460
461#[must_use]
464pub fn similarity(a: &str, b: &str) -> f64 {
465 if a == b {
466 return 1.0;
467 }
468 let (longer, shorter) = if a.chars().count() >= b.chars().count() {
469 (a, b)
470 } else {
471 (b, a)
472 };
473 let llen = longer.chars().count();
474 if llen == 0 {
475 return 1.0;
476 }
477 let dist = edit_distance(longer, shorter);
478 (llen as f64 - dist as f64) / llen as f64
479}
480
481#[must_use]
483pub fn edit_distance(a: &str, b: &str) -> usize {
484 let a_chars: Vec<char> = a.chars().collect();
485 let b_chars: Vec<char> = b.chars().collect();
486 let a_len = a_chars.len();
487 let b_len = b_chars.len();
488 let mut dp: Vec<usize> = (0..=b_len).collect();
489 for i in 1..=a_len {
490 let mut prev = dp[0];
491 dp[0] = i;
492 for j in 1..=b_len {
493 let tmp = dp[j];
494 dp[j] = if a_chars[i - 1] == b_chars[j - 1] {
495 prev
496 } else {
497 1 + dp[j].min(dp[j - 1]).min(prev)
498 };
499 prev = tmp;
500 }
501 }
502 dp[b_len]
503}
504
505impl FailureCode {
506 pub const ALL: &'static [FailureCode] = &[
514 FailureCode::ElementNotFound,
515 FailureCode::NotVisible,
516 FailureCode::NotEnabled,
517 FailureCode::Ambiguous,
518 FailureCode::Timeout,
519 FailureCode::AssertionFailed,
520 FailureCode::AppNotRunning,
521 FailureCode::SimulatorNotBooted,
522 FailureCode::TapMissed,
523 FailureCode::CoordinateSpaceMismatch,
524 FailureCode::DriverError,
525 FailureCode::CaptureBackpressure,
526 FailureCode::AppNotInForeground,
527 ];
528
529 pub fn may_be_outside_the_app(self) -> bool {
532 match self {
533 FailureCode::ElementNotFound
534 | FailureCode::NotVisible
535 | FailureCode::AppNotInForeground => true,
536 FailureCode::NotEnabled
537 | FailureCode::Ambiguous
538 | FailureCode::Timeout
539 | FailureCode::AssertionFailed
540 | FailureCode::AppNotRunning
541 | FailureCode::SimulatorNotBooted
542 | FailureCode::TapMissed
543 | FailureCode::CoordinateSpaceMismatch
544 | FailureCode::DriverError
545 | FailureCode::CaptureBackpressure => false,
546 }
547 }
548
549 pub fn judges_the_screen(self) -> bool {
555 match self {
556 FailureCode::ElementNotFound
557 | FailureCode::NotVisible
558 | FailureCode::NotEnabled
559 | FailureCode::Ambiguous
560 | FailureCode::Timeout
561 | FailureCode::AssertionFailed
562 | FailureCode::TapMissed
563 | FailureCode::CoordinateSpaceMismatch => true,
564 FailureCode::AppNotRunning
565 | FailureCode::SimulatorNotBooted
566 | FailureCode::DriverError
567 | FailureCode::CaptureBackpressure
568 | FailureCode::AppNotInForeground => false,
569 }
570 }
571}
572
573#[cfg(test)]
574mod what_a_code_is_about {
575 use super::*;
576
577 #[test]
581 fn a_verdict_is_about_the_screen_and_a_driver_failure_is_not() {
582 let about_the_screen = [
583 FailureCode::ElementNotFound,
584 FailureCode::NotVisible,
585 FailureCode::NotEnabled,
586 FailureCode::Ambiguous,
587 FailureCode::Timeout,
588 FailureCode::AssertionFailed,
589 FailureCode::TapMissed,
590 FailureCode::CoordinateSpaceMismatch,
591 ];
592 let could_not_look = [
593 FailureCode::AppNotRunning,
594 FailureCode::SimulatorNotBooted,
595 FailureCode::DriverError,
596 FailureCode::CaptureBackpressure,
597 FailureCode::AppNotInForeground,
598 ];
599 for c in about_the_screen {
600 assert!(c.judges_the_screen(), "{c:?} is a verdict about the screen");
601 }
602 for c in could_not_look {
603 assert!(
604 !c.judges_the_screen(),
605 "{c:?} says smix could not look, not what it saw"
606 );
607 }
608 assert_eq!(
609 about_the_screen.len() + could_not_look.len(),
610 FailureCode::ALL.len(),
611 "every code is on one side or the other"
612 );
613 }
614}
615
616#[cfg(test)]
617mod vocabulary {
618 use super::*;
619
620 #[test]
631 fn the_vocabulary_is_pinned() {
632 let every = FailureCode::ALL;
633 for &code in every {
634 match code {
635 FailureCode::ElementNotFound
636 | FailureCode::NotVisible
637 | FailureCode::NotEnabled
638 | FailureCode::Ambiguous
639 | FailureCode::Timeout
640 | FailureCode::AssertionFailed
641 | FailureCode::AppNotRunning
642 | FailureCode::SimulatorNotBooted
643 | FailureCode::TapMissed
644 | FailureCode::CoordinateSpaceMismatch
645 | FailureCode::DriverError
646 | FailureCode::CaptureBackpressure
647 | FailureCode::AppNotInForeground => {}
648 }
649 assert!(!format_code(code).is_empty(), "{code:?} has no wire string");
653 }
654 assert_eq!(
655 every.len(),
656 13,
657 "counted off the list above, not remembered"
658 );
659 }
660}