#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
#![doc(html_root_url = "https://docs.smix.dev/smix-error")]
mod redact;
pub use redact::{MASK, mask as mask_secret};
use serde::{Deserialize, Serialize};
use smix_screen::{ElementSummary, ScreenFacts, WindowInfo, WindowKind};
use smix_selector::{Selector, describe_selector};
use std::fmt;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum FailureCode {
ElementNotFound,
NotVisible,
NotEnabled,
Ambiguous,
Timeout,
AssertionFailed,
AppNotRunning,
SimulatorNotBooted,
TapMissed,
CoordinateSpaceMismatch,
DriverError,
CaptureBackpressure,
AppNotInForeground,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExpectationFailure {
pub ok: False,
pub code: FailureCode,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<Selector>,
#[serde(default)]
pub suggestions: Vec<String>,
#[serde(default)]
pub visible_elements: Vec<ElementSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
#[serde(default)]
pub smix_version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub screenshot: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub device_log: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visible_total: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub windows: Vec<WindowInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unreadable_windows: Option<u32>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct False(pub bool);
#[derive(Default)]
pub struct FailureInit {
pub code: Option<FailureCode>,
pub message: String,
pub selector: Option<Selector>,
pub suggestions: Vec<String>,
pub visible_elements: Vec<ElementSummary>,
pub hint: Option<String>,
pub screenshot: Option<String>,
pub device_log: Vec<String>,
pub visible_total: Option<usize>,
pub windows: Vec<WindowInfo>,
pub unreadable_windows: Option<u32>,
}
impl FailureInit {
#[must_use]
pub fn with_screen(mut self, facts: ScreenFacts) -> Self {
self.visible_elements = facts.elements;
self.visible_total = Some(facts.total);
self.windows = facts.windows;
self.unreadable_windows = (facts.unreadable > 0).then_some(facts.unreadable);
self
}
#[must_use]
pub fn with_screen_from(mut self, earlier: &ExpectationFailure) -> Self {
self.visible_elements = earlier.visible_elements.clone();
self.visible_total = earlier.visible_total;
self.windows = earlier.windows.clone();
self.unreadable_windows = earlier.unreadable_windows;
self
}
}
impl ExpectationFailure {
pub fn new(init: FailureInit) -> Self {
ExpectationFailure {
ok: False(false),
code: init.code.unwrap_or(FailureCode::DriverError),
message: init.message,
selector: init.selector,
suggestions: init.suggestions,
visible_elements: init.visible_elements,
hint: init.hint,
smix_version: env!("CARGO_PKG_VERSION").to_string(),
screenshot: init.screenshot,
device_log: init.device_log,
visible_total: init.visible_total,
windows: init.windows,
unreadable_windows: init.unreadable_windows,
}
}
#[must_use]
pub fn to_prompt(&self) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push(format!(
"FAIL [{}]: {}",
format_code(self.code),
self.message
));
if !self.smix_version.is_empty() {
lines.push(format!(" smix: {}", self.smix_version));
}
if let Some(sel) = &self.selector {
lines.push(format!(" selector: {}", describe_selector(sel)));
}
if !self.suggestions.is_empty() {
lines.push(" suggestions:".into());
for s in &self.suggestions {
lines.push(format!(" - {}", s));
}
}
if let Some(line) = on_screen_line(&self.windows, self.unreadable_windows) {
lines.push(line);
}
if !self.visible_elements.is_empty() {
let n = self.visible_elements.len().min(10);
lines.push(match self.visible_total {
Some(total) if self.windows.iter().any(|w| w.focused) => {
format!(" visible elements ({n} of {total}, the focused app's first):")
}
Some(total) => format!(" visible elements ({n} of {total}):"),
None => format!(" visible elements (top {n}):"),
});
for el in self.visible_elements.iter().take(10) {
lines.push(format!(" - {}", render_element(el)));
}
let rest: Vec<String> = self.visible_elements[n..]
.iter()
.filter_map(|e| e.name.as_ref().or(e.text.as_ref()))
.map(|t| format!("{t:?}"))
.collect();
if !rest.is_empty() {
lines.push(format!(
" the rest of the screen's text: {}",
rest.join(", ")
));
}
}
if let Some(h) = &self.hint {
lines.push(format!(" hint: {}", h));
}
if !self.device_log.is_empty() {
const LOG_PROMPT_CAP: usize = 200;
let n = self.device_log.len();
lines.push(format!(" device log (last {} lines):", n));
let start = n.saturating_sub(LOG_PROMPT_CAP);
for dl in &self.device_log[start..] {
lines.push(format!(" - {}", dl));
}
}
lines.join("\n")
}
}
impl fmt::Display for ExpectationFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_prompt())
}
}
impl std::error::Error for ExpectationFailure {}
fn on_screen_line(windows: &[WindowInfo], unreadable: Option<u32>) -> Option<String> {
let mut parts: Vec<(String, usize)> = Vec::new();
for w in windows {
let who = w.package.as_deref().unwrap_or("(package not reported)");
let kind = match w.kind {
WindowKind::Application => "application",
WindowKind::InputMethod => "keyboard",
WindowKind::System => "system",
WindowKind::Other => "other",
};
let label = if w.focused {
format!("{who} ({kind}, focused)")
} else {
format!("{who} ({kind})")
};
match parts.last_mut() {
Some((last, n)) if *last == label => *n += 1,
_ => parts.push((label, 1)),
}
}
let mut bits: Vec<String> = parts
.into_iter()
.map(|(l, n)| if n > 1 { format!("{l} ×{n}") } else { l })
.collect();
match unreadable {
Some(1) => bits.push("1 window unreadable".into()),
Some(n) if n > 1 => bits.push(format!("{n} windows unreadable")),
_ => {}
}
(!bits.is_empty()).then(|| format!(" on screen: {}", bits.join(" · ")))
}
fn format_code(c: FailureCode) -> &'static str {
match c {
FailureCode::ElementNotFound => "ELEMENT_NOT_FOUND",
FailureCode::NotVisible => "NOT_VISIBLE",
FailureCode::NotEnabled => "NOT_ENABLED",
FailureCode::Ambiguous => "AMBIGUOUS",
FailureCode::Timeout => "TIMEOUT",
FailureCode::AssertionFailed => "ASSERTION_FAILED",
FailureCode::AppNotRunning => "APP_NOT_RUNNING",
FailureCode::SimulatorNotBooted => "SIMULATOR_NOT_BOOTED",
FailureCode::TapMissed => "TAP_MISSED",
FailureCode::CoordinateSpaceMismatch => "COORDINATE_SPACE_MISMATCH",
FailureCode::DriverError => "DRIVER_ERROR",
FailureCode::CaptureBackpressure => "CAPTURE_BACKPRESSURE",
FailureCode::AppNotInForeground => "APP_NOT_IN_FOREGROUND",
}
}
fn render_element(el: &ElementSummary) -> String {
let role_str = el.role.map(|r| r.as_str()).unwrap_or("unknown");
let mut bits: Vec<String> = vec![role_str.to_string()];
if let Some(n) = &el.name {
bits.push(format!("name={:?}", n));
}
if let Some(i) = &el.id {
bits.push(format!("id=\"{}\"", i));
}
if let Some(t) = &el.text
&& Some(t) != el.name.as_ref()
{
bits.push(format!("text={:?}", t));
}
if !el.enabled {
bits.push("disabled".into());
}
bits.join(" ")
}
mod suggest;
pub use suggest::{build_suggestions, edit_distance, similarity};
impl FailureCode {
pub const ALL: &'static [FailureCode] = &[
FailureCode::ElementNotFound,
FailureCode::NotVisible,
FailureCode::NotEnabled,
FailureCode::Ambiguous,
FailureCode::Timeout,
FailureCode::AssertionFailed,
FailureCode::AppNotRunning,
FailureCode::SimulatorNotBooted,
FailureCode::TapMissed,
FailureCode::CoordinateSpaceMismatch,
FailureCode::DriverError,
FailureCode::CaptureBackpressure,
FailureCode::AppNotInForeground,
];
pub fn may_be_outside_the_app(self) -> bool {
match self {
FailureCode::ElementNotFound
| FailureCode::NotVisible
| FailureCode::AppNotInForeground => true,
FailureCode::NotEnabled
| FailureCode::Ambiguous
| FailureCode::Timeout
| FailureCode::AssertionFailed
| FailureCode::AppNotRunning
| FailureCode::SimulatorNotBooted
| FailureCode::TapMissed
| FailureCode::CoordinateSpaceMismatch
| FailureCode::DriverError
| FailureCode::CaptureBackpressure => false,
}
}
pub fn judges_the_screen(self) -> bool {
match self {
FailureCode::ElementNotFound
| FailureCode::NotVisible
| FailureCode::NotEnabled
| FailureCode::Ambiguous
| FailureCode::Timeout
| FailureCode::AssertionFailed
| FailureCode::TapMissed
| FailureCode::CoordinateSpaceMismatch => true,
FailureCode::AppNotRunning
| FailureCode::SimulatorNotBooted
| FailureCode::DriverError
| FailureCode::CaptureBackpressure
| FailureCode::AppNotInForeground => false,
}
}
}
#[cfg(test)]
mod what_a_code_is_about {
use super::*;
#[test]
fn a_verdict_is_about_the_screen_and_a_driver_failure_is_not() {
let about_the_screen = [
FailureCode::ElementNotFound,
FailureCode::NotVisible,
FailureCode::NotEnabled,
FailureCode::Ambiguous,
FailureCode::Timeout,
FailureCode::AssertionFailed,
FailureCode::TapMissed,
FailureCode::CoordinateSpaceMismatch,
];
let could_not_look = [
FailureCode::AppNotRunning,
FailureCode::SimulatorNotBooted,
FailureCode::DriverError,
FailureCode::CaptureBackpressure,
FailureCode::AppNotInForeground,
];
for c in about_the_screen {
assert!(c.judges_the_screen(), "{c:?} is a verdict about the screen");
}
for c in could_not_look {
assert!(
!c.judges_the_screen(),
"{c:?} says smix could not look, not what it saw"
);
}
assert_eq!(
about_the_screen.len() + could_not_look.len(),
FailureCode::ALL.len(),
"every code is on one side or the other"
);
}
}
#[cfg(test)]
mod vocabulary {
use super::*;
#[test]
fn the_vocabulary_is_pinned() {
let every = FailureCode::ALL;
for &code in every {
match code {
FailureCode::ElementNotFound
| FailureCode::NotVisible
| FailureCode::NotEnabled
| FailureCode::Ambiguous
| FailureCode::Timeout
| FailureCode::AssertionFailed
| FailureCode::AppNotRunning
| FailureCode::SimulatorNotBooted
| FailureCode::TapMissed
| FailureCode::CoordinateSpaceMismatch
| FailureCode::DriverError
| FailureCode::CaptureBackpressure
| FailureCode::AppNotInForeground => {}
}
assert!(!format_code(code).is_empty(), "{code:?} has no wire string");
}
assert_eq!(
every.len(),
13,
"counted off the list above, not remembered"
);
}
}