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")]
21
22use serde::{Deserialize, Serialize};
23use smix_screen::ElementSummary;
24use smix_selector::{Selector, describe_selector};
25use std::fmt;
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
31#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
32pub enum FailureCode {
33 ElementNotFound,
35 NotVisible,
37 NotEnabled,
39 Ambiguous,
41 Timeout,
43 AssertionFailed,
45 AppNotRunning,
47 SimulatorNotBooted,
49 DriverError,
51}
52
53#[derive(Clone, Debug, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct ExpectationFailure {
67 pub ok: False,
70 pub code: FailureCode,
72 pub message: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub selector: Option<Selector>,
77 #[serde(default)]
79 pub suggestions: Vec<String>,
80 #[serde(default)]
82 pub visible_elements: Vec<ElementSummary>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub hint: Option<String>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub screenshot: Option<String>,
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
92 pub device_log: Vec<String>,
93}
94
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(transparent)]
99pub struct False(pub bool);
100
101#[derive(Default)]
103pub struct FailureInit {
104 pub code: Option<FailureCode>,
106 pub message: String,
108 pub selector: Option<Selector>,
110 pub suggestions: Vec<String>,
112 pub visible_elements: Vec<ElementSummary>,
114 pub hint: Option<String>,
116 pub screenshot: Option<String>,
118 pub device_log: Vec<String>,
120}
121
122impl ExpectationFailure {
123 pub fn new(init: FailureInit) -> Self {
127 ExpectationFailure {
128 ok: False(false),
129 code: init.code.unwrap_or(FailureCode::DriverError),
130 message: init.message,
131 selector: init.selector,
132 suggestions: init.suggestions,
133 visible_elements: init.visible_elements,
134 hint: init.hint,
135 screenshot: init.screenshot,
136 device_log: init.device_log,
137 }
138 }
139
140 #[must_use]
143 pub fn to_prompt(&self) -> String {
144 let mut lines: Vec<String> = Vec::new();
145 lines.push(format!(
146 "FAIL [{}]: {}",
147 format_code(self.code),
148 self.message
149 ));
150 if let Some(sel) = &self.selector {
151 lines.push(format!(" selector: {}", describe_selector(sel)));
152 }
153 if !self.suggestions.is_empty() {
154 lines.push(" suggestions:".into());
155 for s in &self.suggestions {
156 lines.push(format!(" - {}", s));
157 }
158 }
159 if !self.visible_elements.is_empty() {
160 let n = self.visible_elements.len().min(10);
161 lines.push(format!(" visible elements (top {}):", n));
162 for el in self.visible_elements.iter().take(10) {
163 lines.push(format!(" - {}", render_element(el)));
164 }
165 }
166 if let Some(h) = &self.hint {
167 lines.push(format!(" hint: {}", h));
168 }
169 if !self.device_log.is_empty() {
170 const LOG_PROMPT_CAP: usize = 200;
172 let n = self.device_log.len();
173 lines.push(format!(" device log (last {} lines):", n));
174 let start = n.saturating_sub(LOG_PROMPT_CAP);
175 for dl in &self.device_log[start..] {
176 lines.push(format!(" - {}", dl));
177 }
178 }
179 lines.join("\n")
180 }
181}
182
183impl fmt::Display for ExpectationFailure {
184 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185 write!(f, "{}", self.to_prompt())
186 }
187}
188
189impl std::error::Error for ExpectationFailure {}
190
191fn format_code(c: FailureCode) -> &'static str {
192 match c {
193 FailureCode::ElementNotFound => "ELEMENT_NOT_FOUND",
194 FailureCode::NotVisible => "NOT_VISIBLE",
195 FailureCode::NotEnabled => "NOT_ENABLED",
196 FailureCode::Ambiguous => "AMBIGUOUS",
197 FailureCode::Timeout => "TIMEOUT",
198 FailureCode::AssertionFailed => "ASSERTION_FAILED",
199 FailureCode::AppNotRunning => "APP_NOT_RUNNING",
200 FailureCode::SimulatorNotBooted => "SIMULATOR_NOT_BOOTED",
201 FailureCode::DriverError => "DRIVER_ERROR",
202 }
203}
204
205fn render_element(el: &ElementSummary) -> String {
206 let role_str = el.role.map(|r| r.as_str()).unwrap_or("unknown");
207 let mut bits: Vec<String> = vec![role_str.to_string()];
208 if let Some(n) = &el.name {
209 bits.push(format!("name={:?}", n));
210 }
211 if let Some(i) = &el.id {
212 bits.push(format!("id=\"{}\"", i));
213 }
214 if let Some(t) = &el.text
215 && Some(t) != el.name.as_ref()
216 {
217 bits.push(format!("text={:?}", t));
218 }
219 if !el.enabled {
220 bits.push("disabled".into());
221 }
222 bits.join(" ")
223}
224
225const SUGGESTION_THRESHOLD: f64 = 0.5;
228const SUGGESTION_TOP_N: usize = 3;
229
230#[must_use]
236pub fn build_suggestions(target: Option<&str>, visible: &[ElementSummary]) -> Vec<String> {
237 let Some(target) = target else {
238 return Vec::new();
239 };
240 let lower_target = target.to_lowercase();
241 let mut candidates: Vec<(f64, &'static str, String, usize)> = Vec::new();
242 for (i, el) in visible.iter().enumerate() {
243 let mut best: Option<(f64, &'static str, String)> = None;
244 if let Some(name) = &el.name
245 && !name.is_empty()
246 {
247 let s = similarity(&name.to_lowercase(), &lower_target);
248 if s > SUGGESTION_THRESHOLD {
249 best = Some((s, "name", name.clone()));
250 }
251 }
252 if let Some(text) = &el.text
253 && !text.is_empty()
254 {
255 let s = similarity(&text.to_lowercase(), &lower_target);
256 if s > SUGGESTION_THRESHOLD && best.as_ref().map(|(bs, _, _)| s > *bs).unwrap_or(true) {
257 best = Some((s, "text", text.clone()));
258 }
259 }
260 if let Some((score, field, value)) = best {
261 candidates.push((score, field, value, i));
262 }
263 }
264 candidates.sort_by(|a, b| {
266 b.0.partial_cmp(&a.0)
267 .unwrap_or(std::cmp::Ordering::Equal)
268 .then_with(|| match (a.1, b.1) {
269 ("name", "text") => std::cmp::Ordering::Less,
270 ("text", "name") => std::cmp::Ordering::Greater,
271 _ => std::cmp::Ordering::Equal,
272 })
273 .then_with(|| a.3.cmp(&b.3))
274 });
275 candidates
276 .into_iter()
277 .take(SUGGESTION_TOP_N)
278 .map(|(score, field, value, _)| {
279 format!(
280 "Did you mean {:?}? (similarity {:.2}, field {})",
281 value, score, field
282 )
283 })
284 .collect()
285}
286
287#[must_use]
290pub fn similarity(a: &str, b: &str) -> f64 {
291 if a == b {
292 return 1.0;
293 }
294 let (longer, shorter) = if a.chars().count() >= b.chars().count() {
295 (a, b)
296 } else {
297 (b, a)
298 };
299 let llen = longer.chars().count();
300 if llen == 0 {
301 return 1.0;
302 }
303 let dist = edit_distance(longer, shorter);
304 (llen as f64 - dist as f64) / llen as f64
305}
306
307#[must_use]
309pub fn edit_distance(a: &str, b: &str) -> usize {
310 let a_chars: Vec<char> = a.chars().collect();
311 let b_chars: Vec<char> = b.chars().collect();
312 let a_len = a_chars.len();
313 let b_len = b_chars.len();
314 let mut dp: Vec<usize> = (0..=b_len).collect();
315 for i in 1..=a_len {
316 let mut prev = dp[0];
317 dp[0] = i;
318 for j in 1..=b_len {
319 let tmp = dp[j];
320 dp[j] = if a_chars[i - 1] == b_chars[j - 1] {
321 prev
322 } else {
323 1 + dp[j].min(dp[j - 1]).min(prev)
324 };
325 prev = tmp;
326 }
327 }
328 dp[b_len]
329}