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")]
19
20use serde::{Deserialize, Serialize};
21use smix_screen::ElementSummary;
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")]
29pub enum FailureCode {
30 ElementNotFound,
32 NotVisible,
34 NotEnabled,
36 Ambiguous,
38 Timeout,
40 AssertionFailed,
42 AppNotRunning,
44 SimulatorNotBooted,
46 TapMissed,
55 DriverError,
57}
58
59#[derive(Clone, Debug, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct ExpectationFailure {
73 pub ok: False,
76 pub code: FailureCode,
78 pub message: String,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub selector: Option<Selector>,
83 #[serde(default)]
85 pub suggestions: Vec<String>,
86 #[serde(default)]
88 pub visible_elements: Vec<ElementSummary>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub hint: Option<String>,
92 #[serde(default)]
104 pub smix_version: String,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub screenshot: Option<String>,
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub device_log: Vec<String>,
112}
113
114#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(transparent)]
118pub struct False(pub bool);
119
120#[derive(Default)]
122pub struct FailureInit {
123 pub code: Option<FailureCode>,
125 pub message: String,
127 pub selector: Option<Selector>,
129 pub suggestions: Vec<String>,
131 pub visible_elements: Vec<ElementSummary>,
133 pub hint: Option<String>,
135 pub screenshot: Option<String>,
137 pub device_log: Vec<String>,
139}
140
141impl ExpectationFailure {
142 pub fn new(init: FailureInit) -> Self {
146 ExpectationFailure {
147 ok: False(false),
148 code: init.code.unwrap_or(FailureCode::DriverError),
149 message: init.message,
150 selector: init.selector,
151 suggestions: init.suggestions,
152 visible_elements: init.visible_elements,
153 hint: init.hint,
154 smix_version: env!("CARGO_PKG_VERSION").to_string(),
155 screenshot: init.screenshot,
156 device_log: init.device_log,
157 }
158 }
159
160 #[must_use]
163 pub fn to_prompt(&self) -> String {
164 let mut lines: Vec<String> = Vec::new();
165 lines.push(format!(
166 "FAIL [{}]: {}",
167 format_code(self.code),
168 self.message
169 ));
170 if !self.smix_version.is_empty() {
175 lines.push(format!(" smix: {}", self.smix_version));
176 }
177 if let Some(sel) = &self.selector {
178 lines.push(format!(" selector: {}", describe_selector(sel)));
179 }
180 if !self.suggestions.is_empty() {
181 lines.push(" suggestions:".into());
182 for s in &self.suggestions {
183 lines.push(format!(" - {}", s));
184 }
185 }
186 if !self.visible_elements.is_empty() {
187 let n = self.visible_elements.len().min(10);
188 lines.push(format!(" visible elements (top {}):", n));
189 for el in self.visible_elements.iter().take(10) {
190 lines.push(format!(" - {}", render_element(el)));
191 }
192 }
193 if let Some(h) = &self.hint {
194 lines.push(format!(" hint: {}", h));
195 }
196 if !self.device_log.is_empty() {
197 const LOG_PROMPT_CAP: usize = 200;
199 let n = self.device_log.len();
200 lines.push(format!(" device log (last {} lines):", n));
201 let start = n.saturating_sub(LOG_PROMPT_CAP);
202 for dl in &self.device_log[start..] {
203 lines.push(format!(" - {}", dl));
204 }
205 }
206 lines.join("\n")
207 }
208}
209
210impl fmt::Display for ExpectationFailure {
211 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212 write!(f, "{}", self.to_prompt())
213 }
214}
215
216impl std::error::Error for ExpectationFailure {}
217
218fn format_code(c: FailureCode) -> &'static str {
219 match c {
220 FailureCode::ElementNotFound => "ELEMENT_NOT_FOUND",
221 FailureCode::NotVisible => "NOT_VISIBLE",
222 FailureCode::NotEnabled => "NOT_ENABLED",
223 FailureCode::Ambiguous => "AMBIGUOUS",
224 FailureCode::Timeout => "TIMEOUT",
225 FailureCode::AssertionFailed => "ASSERTION_FAILED",
226 FailureCode::AppNotRunning => "APP_NOT_RUNNING",
227 FailureCode::SimulatorNotBooted => "SIMULATOR_NOT_BOOTED",
228 FailureCode::TapMissed => "TAP_MISSED",
229 FailureCode::DriverError => "DRIVER_ERROR",
230 }
231}
232
233fn render_element(el: &ElementSummary) -> String {
234 let role_str = el.role.map(|r| r.as_str()).unwrap_or("unknown");
235 let mut bits: Vec<String> = vec![role_str.to_string()];
236 if let Some(n) = &el.name {
237 bits.push(format!("name={:?}", n));
238 }
239 if let Some(i) = &el.id {
240 bits.push(format!("id=\"{}\"", i));
241 }
242 if let Some(t) = &el.text
243 && Some(t) != el.name.as_ref()
244 {
245 bits.push(format!("text={:?}", t));
246 }
247 if !el.enabled {
248 bits.push("disabled".into());
249 }
250 bits.join(" ")
251}
252
253const SUGGESTION_THRESHOLD: f64 = 0.5;
256const SUGGESTION_TOP_N: usize = 3;
257
258#[must_use]
264pub fn build_suggestions(target: Option<&str>, visible: &[ElementSummary]) -> Vec<String> {
265 let Some(target) = target else {
266 return Vec::new();
267 };
268 let lower_target = target.to_lowercase();
269 let mut candidates: Vec<(f64, &'static str, String, usize)> = Vec::new();
270 for (i, el) in visible.iter().enumerate() {
271 let mut best: Option<(f64, &'static str, String)> = None;
272 if let Some(name) = &el.name
273 && !name.is_empty()
274 {
275 let s = similarity(&name.to_lowercase(), &lower_target);
276 if s > SUGGESTION_THRESHOLD {
277 best = Some((s, "name", name.clone()));
278 }
279 }
280 if let Some(id) = &el.id
281 && !id.is_empty()
282 {
283 let s = similarity(&id.to_lowercase(), &lower_target);
284 if s > SUGGESTION_THRESHOLD && best.as_ref().map(|(bs, _, _)| s > *bs).unwrap_or(true) {
285 best = Some((s, "id", id.clone()));
286 }
287 }
288 if let Some(text) = &el.text
289 && !text.is_empty()
290 {
291 let s = similarity(&text.to_lowercase(), &lower_target);
292 if s > SUGGESTION_THRESHOLD && best.as_ref().map(|(bs, _, _)| s > *bs).unwrap_or(true) {
293 best = Some((s, "text", text.clone()));
294 }
295 }
296 if let Some((score, field, value)) = best {
297 candidates.push((score, field, value, i));
298 }
299 }
300 let field_rank = |f: &str| match f {
302 "name" => 0,
303 "id" => 1,
304 _ => 2, };
306 candidates.sort_by(|a, b| {
307 b.0.partial_cmp(&a.0)
308 .unwrap_or(std::cmp::Ordering::Equal)
309 .then_with(|| field_rank(a.1).cmp(&field_rank(b.1)))
310 .then_with(|| a.3.cmp(&b.3))
311 });
312 candidates
313 .into_iter()
314 .take(SUGGESTION_TOP_N)
315 .map(|(score, field, value, _)| {
316 format!(
317 "Did you mean {:?}? (similarity {:.2}, field {})",
318 value, score, field
319 )
320 })
321 .collect()
322}
323
324#[must_use]
327pub fn similarity(a: &str, b: &str) -> f64 {
328 if a == b {
329 return 1.0;
330 }
331 let (longer, shorter) = if a.chars().count() >= b.chars().count() {
332 (a, b)
333 } else {
334 (b, a)
335 };
336 let llen = longer.chars().count();
337 if llen == 0 {
338 return 1.0;
339 }
340 let dist = edit_distance(longer, shorter);
341 (llen as f64 - dist as f64) / llen as f64
342}
343
344#[must_use]
346pub fn edit_distance(a: &str, b: &str) -> usize {
347 let a_chars: Vec<char> = a.chars().collect();
348 let b_chars: Vec<char> = b.chars().collect();
349 let a_len = a_chars.len();
350 let b_len = b_chars.len();
351 let mut dp: Vec<usize> = (0..=b_len).collect();
352 for i in 1..=a_len {
353 let mut prev = dp[0];
354 dp[0] = i;
355 for j in 1..=b_len {
356 let tmp = dp[j];
357 dp[j] = if a_chars[i - 1] == b_chars[j - 1] {
358 prev
359 } else {
360 1 + dp[j].min(dp[j - 1]).min(prev)
361 };
362 prev = tmp;
363 }
364 }
365 dp[b_len]
366}