#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
#![doc(html_root_url = "https://docs.smix.dev/smix-error")]
use serde::{Deserialize, Serialize};
use smix_screen::ElementSummary;
use smix_selector::{Selector, describe_selector};
use std::fmt;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum FailureCode {
ElementNotFound,
NotVisible,
NotEnabled,
Ambiguous,
Timeout,
AssertionFailed,
AppNotRunning,
SimulatorNotBooted,
DriverError,
}
#[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, skip_serializing_if = "Option::is_none")]
pub screenshot: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub device_log: Vec<String>,
}
#[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>,
}
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,
screenshot: init.screenshot,
device_log: init.device_log,
}
}
#[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 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 !self.visible_elements.is_empty() {
let n = self.visible_elements.len().min(10);
lines.push(format!(" visible elements (top {}):", n));
for el in self.visible_elements.iter().take(10) {
lines.push(format!(" - {}", render_element(el)));
}
}
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 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::DriverError => "DRIVER_ERROR",
}
}
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(" ")
}
const SUGGESTION_THRESHOLD: f64 = 0.5;
const SUGGESTION_TOP_N: usize = 3;
#[must_use]
pub fn build_suggestions(target: Option<&str>, visible: &[ElementSummary]) -> Vec<String> {
let Some(target) = target else {
return Vec::new();
};
let lower_target = target.to_lowercase();
let mut candidates: Vec<(f64, &'static str, String, usize)> = Vec::new();
for (i, el) in visible.iter().enumerate() {
let mut best: Option<(f64, &'static str, String)> = None;
if let Some(name) = &el.name
&& !name.is_empty()
{
let s = similarity(&name.to_lowercase(), &lower_target);
if s > SUGGESTION_THRESHOLD {
best = Some((s, "name", name.clone()));
}
}
if let Some(text) = &el.text
&& !text.is_empty()
{
let s = similarity(&text.to_lowercase(), &lower_target);
if s > SUGGESTION_THRESHOLD && best.as_ref().map(|(bs, _, _)| s > *bs).unwrap_or(true) {
best = Some((s, "text", text.clone()));
}
}
if let Some((score, field, value)) = best {
candidates.push((score, field, value, i));
}
}
candidates.sort_by(|a, b| {
b.0.partial_cmp(&a.0)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| match (a.1, b.1) {
("name", "text") => std::cmp::Ordering::Less,
("text", "name") => std::cmp::Ordering::Greater,
_ => std::cmp::Ordering::Equal,
})
.then_with(|| a.3.cmp(&b.3))
});
candidates
.into_iter()
.take(SUGGESTION_TOP_N)
.map(|(score, field, value, _)| {
format!(
"Did you mean {:?}? (similarity {:.2}, field {})",
value, score, field
)
})
.collect()
}
#[must_use]
pub fn similarity(a: &str, b: &str) -> f64 {
if a == b {
return 1.0;
}
let (longer, shorter) = if a.chars().count() >= b.chars().count() {
(a, b)
} else {
(b, a)
};
let llen = longer.chars().count();
if llen == 0 {
return 1.0;
}
let dist = edit_distance(longer, shorter);
(llen as f64 - dist as f64) / llen as f64
}
#[must_use]
pub fn edit_distance(a: &str, b: &str) -> usize {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let a_len = a_chars.len();
let b_len = b_chars.len();
let mut dp: Vec<usize> = (0..=b_len).collect();
for i in 1..=a_len {
let mut prev = dp[0];
dp[0] = i;
for j in 1..=b_len {
let tmp = dp[j];
dp[j] = if a_chars[i - 1] == b_chars[j - 1] {
prev
} else {
1 + dp[j].min(dp[j - 1]).min(prev)
};
prev = tmp;
}
}
dp[b_len]
}