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