smix_error/lib.rs
1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5//! smix-error — ExpectationFailure + FailureCode + AI-readable
6//! `to_prompt()` + `build_suggestions` (stone, cold path).
7//!
8//! `build_suggestions` behavior: threshold > 0.5, top 3, sort by score
9//! descending → field (name > id > text) → DFS index ascending.
10//!
11//! # Why a separate stone
12//!
13//! The canonical failure type, in a leaf crate every layer can depend on;
14//! `to_prompt()` is its AI-readable render.
15
16#![doc(html_root_url = "https://docs.smix.dev/smix-error")]
17
18mod redact;
19pub use redact::{MASK, mask as mask_secret};
20use serde::{Deserialize, Serialize};
21use smix_screen::{ElementSummary, ScreenFacts, WindowInfo, WindowKind};
22use smix_selector::{Selector, describe_selector};
23use std::fmt;
24
25/// All failure codes smix surfaces back to the SDK / MCP / CLI
26/// (SCREAMING_SNAKE_CASE wire).
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
29// non_exhaustive so a new code is additive rather than a major; the guard
30// that makes one fail to compile until it is thought about lives inside
31// this crate (`the_vocabulary_is_pinned`), where the match stays exhaustive.
32#[non_exhaustive]
33pub enum FailureCode {
34 /// Selector matched zero elements in the visible tree.
35 ElementNotFound,
36 /// Element matched but failed the visibility filter.
37 NotVisible,
38 /// Element matched but `enabled = false`.
39 NotEnabled,
40 /// Selector matched multiple elements (when uniqueness was required).
41 Ambiguous,
42 /// Operation exceeded the implicit-wait budget.
43 Timeout,
44 /// `expect` assertion (e.g. `toHaveText`) did not hold.
45 AssertionFailed,
46 /// Target app exited or never launched.
47 AppNotRunning,
48 /// Simulator device is not booted.
49 SimulatorNotBooted,
50 /// The touch was synthesised, and it did not land inside the
51 /// element the selector matched.
52 ///
53 /// Distinct from `ElementNotFound` because the two send a reader
54 /// somewhere different: not-found means fix the selector, missed
55 /// means the element was there and the touch went elsewhere — a
56 /// stale frame, or something moved between the tree fetch and the
57 /// tap.
58 TapMissed,
59 /// The screen is described in one coordinate space and the touch
60 /// would be delivered in another, so no aim can land where the tree
61 /// says the element is.
62 ///
63 /// Distinct from `TapMissed`, and the distinction is the whole
64 /// point: a miss says the element was there and the touch went
65 /// elsewhere, which invites another attempt with a better point.
66 /// There is no better point here — whatever is passed gets
67 /// recomputed against the app's frame and then read against the
68 /// device's. Retrying is the trap, and the consumer who found this
69 /// spent an afternoon in it.
70 CoordinateSpaceMismatch,
71 /// Catch-all for runner / driver / IO failures.
72 DriverError,
73 /// The device's capture path is under load and refusing frames for a
74 /// stated window — the screenshot pacer's circuit is open.
75 ///
76 /// Separate from [`Self::DriverError`] because the two want opposite
77 /// responses and only one of them is a defect. A driver error means
78 /// something is broken; this means "not now, try again shortly", and
79 /// a caller with time left can simply keep waiting. It arrived as a
80 /// `DriverError` until 7.1, which is how a consumer's release gate
81 /// met it: `FAIL [DRIVER_ERROR]` from `waitForAnimationToEnd`, a
82 /// verb whose entire meaning is to wait.
83 CaptureBackpressure,
84 /// The app has no window in front — another app or the home screen is
85 /// there — so its elements cannot be read. Unlike [`Self::AppNotRunning`]
86 /// nothing crashed: bring it back with launchApp or its notification.
87 AppNotInForeground,
88}
89
90/// Structured failure thrown by SDK matchers and driver calls. Shape
91/// is AI-feed-back-ready.
92///
93/// `selector` is the typed `Selector` rather than the wire
94/// `serde_json::Value` — callers raising failures from the driver / SDK
95/// side already have the typed selector, and `to_prompt` invokes
96/// `describe_selector` for stable rendering.
97///
98/// Serializes as `{ ok: false, code, message, selector, suggestions,
99/// visibleElements, hint, screenshot, deviceLog }`; the `ok = false`
100/// discriminant identifies the failure branch of a `Result` wire.
101#[derive(Clone, Debug, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct ExpectationFailure {
104 /// Always `false` for failures. Round-trip discriminator for
105 /// `Result<T, ExpectationFailure>` wire.
106 pub ok: False,
107 /// Failure code discriminator.
108 pub code: FailureCode,
109 /// Human-readable summary (and AI-readable when fed into `to_prompt`).
110 pub message: String,
111 /// Originating selector, when one is available.
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub selector: Option<Selector>,
114 /// Ranked near-miss suggestions (edit-distance to label / id / etc.).
115 #[serde(default)]
116 pub suggestions: Vec<String>,
117 /// Snapshot of visible+enabled elements at failure time.
118 #[serde(default)]
119 pub visible_elements: Vec<ElementSummary>,
120 /// Optional one-line hint (e.g. "try `app.wait_for(...)` first").
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub hint: Option<String>,
123 /// The smix that produced this failure.
124 ///
125 /// Not decoration. A consumer wrote up two defects against smix
126 /// behaviour that had been fixed hours earlier, quoting an error
127 /// message the current build no longer emits — they had no way to
128 /// tell whether the smix in front of them contained the fix for
129 /// the thing they had just hit. A failure that names its version
130 /// makes that answerable from the failure itself.
131 ///
132 /// Set by [`ExpectationFailure::new`] from the crate version, so no
133 /// call site can forget it.
134 #[serde(default)]
135 pub smix_version: String,
136 /// base64-encoded PNG; omitted from default rendering to keep logs lean.
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub screenshot: Option<String>,
139 /// Last-N-lines of captured device log, folded into AI-readable
140 /// output for failure-window system context.
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
142 pub device_log: Vec<String>,
143 /// How many elements `visible_elements` was cut from.
144 ///
145 /// A list of ten read as the whole screen: a consumer built a
146 /// detector on it that called every Android failure blind, because
147 /// the ten were always the status bar. The total says it is a
148 /// sample.
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub visible_total: Option<usize>,
151 /// The windows the screen held when this failed, focused app first.
152 #[serde(default, skip_serializing_if = "Vec::is_empty")]
153 pub windows: Vec<WindowInfo>,
154 /// Windows the reader could not read. "The app is not in the tree"
155 /// and "the app's window could not be read" are different failures.
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub unreadable_windows: Option<u32>,
158}
159
160/// Newtype around `false` — used as the [`ExpectationFailure::ok`]
161/// discriminator. Serializes/deserializes as the JSON literal `false`.
162#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(transparent)]
164pub struct False(pub bool);
165
166/// Builder/Init form for ergonomic construction.
167#[derive(Default)]
168pub struct FailureInit {
169 /// Optional failure code (defaults to `DriverError` if `None`).
170 pub code: Option<FailureCode>,
171 /// Human-readable / AI-readable message.
172 pub message: String,
173 /// Originating selector, when one is available.
174 pub selector: Option<Selector>,
175 /// Pre-built ranked near-miss suggestions.
176 pub suggestions: Vec<String>,
177 /// Captured visible+enabled elements at failure time.
178 pub visible_elements: Vec<ElementSummary>,
179 /// Optional one-line hint string.
180 pub hint: Option<String>,
181 /// Optional base64 PNG screenshot at failure time.
182 pub screenshot: Option<String>,
183 /// Optional captured device log tail.
184 pub device_log: Vec<String>,
185 /// See [`ExpectationFailure::visible_total`].
186 pub visible_total: Option<usize>,
187 /// See [`ExpectationFailure::windows`].
188 pub windows: Vec<WindowInfo>,
189 /// See [`ExpectationFailure::unreadable_windows`].
190 pub unreadable_windows: Option<u32>,
191}
192
193impl FailureInit {
194 /// The screen the failure happened on: its elements (already in the
195 /// order a reader should meet them), how many there were, whose
196 /// windows they sat in, and how many windows could not be read.
197 ///
198 /// One entry point, so a call site cannot hand over the list and
199 /// forget the count that says it is a sample.
200 #[must_use]
201 pub fn with_screen(mut self, facts: ScreenFacts) -> Self {
202 self.visible_elements = facts.elements;
203 self.visible_total = Some(facts.total);
204 self.windows = facts.windows;
205 self.unreadable_windows = (facts.unreadable > 0).then_some(facts.unreadable);
206 self
207 }
208
209 /// The screen an earlier failure described, carried into a new one.
210 ///
211 /// For a failure that re-states another (a timeout reported as "not
212 /// visible"): copying `visible_elements` by hand left the count and
213 /// the windows behind, and the next field added would be left behind
214 /// the same way.
215 #[must_use]
216 pub fn with_screen_from(mut self, earlier: &ExpectationFailure) -> Self {
217 self.visible_elements = earlier.visible_elements.clone();
218 self.visible_total = earlier.visible_total;
219 self.windows = earlier.windows.clone();
220 self.unreadable_windows = earlier.unreadable_windows;
221 self
222 }
223}
224
225impl ExpectationFailure {
226 /// Construct from an init struct. `code` defaults to `DriverError`
227 /// if `init.code` was None (defensive; SDK callers should always
228 /// set it).
229 pub fn new(init: FailureInit) -> Self {
230 ExpectationFailure {
231 ok: False(false),
232 code: init.code.unwrap_or(FailureCode::DriverError),
233 message: init.message,
234 selector: init.selector,
235 suggestions: init.suggestions,
236 visible_elements: init.visible_elements,
237 hint: init.hint,
238 smix_version: env!("CARGO_PKG_VERSION").to_string(),
239 screenshot: init.screenshot,
240 device_log: init.device_log,
241 visible_total: init.visible_total,
242 windows: init.windows,
243 unreadable_windows: init.unreadable_windows,
244 }
245 }
246
247 /// AI-facing rendering. Designed so the output can be pasted back
248 /// as a user message into a coding agent.
249 #[must_use]
250 pub fn to_prompt(&self) -> String {
251 let mut lines: Vec<String> = Vec::new();
252 lines.push(format!(
253 "FAIL [{}]: {}",
254 format_code(self.code),
255 self.message
256 ));
257 // The version, on every failure, because the reader's next
258 // question after "what went wrong" is often "is my smix old".
259 // A consumer once wrote up two defects that had been fixed
260 // hours earlier, quoting a message this build no longer emits.
261 if !self.smix_version.is_empty() {
262 lines.push(format!(" smix: {}", self.smix_version));
263 }
264 if let Some(sel) = &self.selector {
265 lines.push(format!(" selector: {}", describe_selector(sel)));
266 }
267 if !self.suggestions.is_empty() {
268 lines.push(" suggestions:".into());
269 for s in &self.suggestions {
270 lines.push(format!(" - {}", s));
271 }
272 }
273 if let Some(line) = on_screen_line(&self.windows, self.unreadable_windows) {
274 lines.push(line);
275 }
276 if !self.visible_elements.is_empty() {
277 let n = self.visible_elements.len().min(10);
278 lines.push(match self.visible_total {
279 Some(total) if self.windows.iter().any(|w| w.focused) => {
280 format!(" visible elements ({n} of {total}, the focused app's first):")
281 }
282 Some(total) => format!(" visible elements ({n} of {total}):"),
283 None => format!(" visible elements (top {n}):"),
284 });
285 for el in self.visible_elements.iter().take(10) {
286 lines.push(format!(" - {}", render_element(el)));
287 }
288 let rest: Vec<String> = self.visible_elements[n..]
289 .iter()
290 .filter_map(|e| e.name.as_ref().or(e.text.as_ref()))
291 .map(|t| format!("{t:?}"))
292 .collect();
293 if !rest.is_empty() {
294 lines.push(format!(
295 " the rest of the screen's text: {}",
296 rest.join(", ")
297 ));
298 }
299 }
300 if let Some(h) = &self.hint {
301 lines.push(format!(" hint: {}", h));
302 }
303 if !self.device_log.is_empty() {
304 // LOG_PROMPT_CAP — defensive ceiling for AI prompt size.
305 const LOG_PROMPT_CAP: usize = 200;
306 let n = self.device_log.len();
307 lines.push(format!(" device log (last {} lines):", n));
308 let start = n.saturating_sub(LOG_PROMPT_CAP);
309 for dl in &self.device_log[start..] {
310 lines.push(format!(" - {}", dl));
311 }
312 }
313 lines.join("\n")
314 }
315}
316
317impl fmt::Display for ExpectationFailure {
318 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
319 write!(f, "{}", self.to_prompt())
320 }
321}
322
323impl std::error::Error for ExpectationFailure {}
324
325/// One line naming the windows a failure's screen held.
326///
327/// Repeats collapse (`×2`) because a status bar and a navigation bar are
328/// both `com.android.systemui`, and listing it twice says nothing the
329/// count does not. `None` when there is nothing to say — a tree that
330/// carries no window information gets no line rather than an empty one.
331fn on_screen_line(windows: &[WindowInfo], unreadable: Option<u32>) -> Option<String> {
332 let mut parts: Vec<(String, usize)> = Vec::new();
333 for w in windows {
334 let who = w.package.as_deref().unwrap_or("(package not reported)");
335 let kind = match w.kind {
336 WindowKind::Application => "application",
337 WindowKind::InputMethod => "keyboard",
338 WindowKind::System => "system",
339 WindowKind::Other => "other",
340 };
341 let label = if w.focused {
342 format!("{who} ({kind}, focused)")
343 } else {
344 format!("{who} ({kind})")
345 };
346 match parts.last_mut() {
347 Some((last, n)) if *last == label => *n += 1,
348 _ => parts.push((label, 1)),
349 }
350 }
351 let mut bits: Vec<String> = parts
352 .into_iter()
353 .map(|(l, n)| if n > 1 { format!("{l} ×{n}") } else { l })
354 .collect();
355 match unreadable {
356 Some(1) => bits.push("1 window unreadable".into()),
357 Some(n) if n > 1 => bits.push(format!("{n} windows unreadable")),
358 _ => {}
359 }
360 (!bits.is_empty()).then(|| format!(" on screen: {}", bits.join(" · ")))
361}
362
363fn format_code(c: FailureCode) -> &'static str {
364 match c {
365 FailureCode::ElementNotFound => "ELEMENT_NOT_FOUND",
366 FailureCode::NotVisible => "NOT_VISIBLE",
367 FailureCode::NotEnabled => "NOT_ENABLED",
368 FailureCode::Ambiguous => "AMBIGUOUS",
369 FailureCode::Timeout => "TIMEOUT",
370 FailureCode::AssertionFailed => "ASSERTION_FAILED",
371 FailureCode::AppNotRunning => "APP_NOT_RUNNING",
372 FailureCode::SimulatorNotBooted => "SIMULATOR_NOT_BOOTED",
373 FailureCode::TapMissed => "TAP_MISSED",
374 FailureCode::CoordinateSpaceMismatch => "COORDINATE_SPACE_MISMATCH",
375 FailureCode::DriverError => "DRIVER_ERROR",
376 FailureCode::CaptureBackpressure => "CAPTURE_BACKPRESSURE",
377 FailureCode::AppNotInForeground => "APP_NOT_IN_FOREGROUND",
378 }
379}
380
381fn render_element(el: &ElementSummary) -> String {
382 let role_str = el.role.map(|r| r.as_str()).unwrap_or("unknown");
383 let mut bits: Vec<String> = vec![role_str.to_string()];
384 if let Some(n) = &el.name {
385 bits.push(format!("name={:?}", n));
386 }
387 if let Some(i) = &el.id {
388 bits.push(format!("id=\"{}\"", i));
389 }
390 if let Some(t) = &el.text
391 && Some(t) != el.name.as_ref()
392 {
393 bits.push(format!("text={:?}", t));
394 }
395 if !el.enabled {
396 bits.push("disabled".into());
397 }
398 bits.join(" ")
399}
400
401mod suggest;
402pub use suggest::{build_suggestions, edit_distance, similarity};
403
404impl FailureCode {
405 /// Every code, once.
406 ///
407 /// The one list: the SDK parity test and the errors guide read it
408 /// rather than keeping their own (a copy outside this crate let
409 /// `CaptureBackpressure` ship in 7.1 with no SDK declaring it, since
410 /// `#[non_exhaustive]` makes an outside `match` accept anything).
411 /// `the_vocabulary_is_pinned` guards it with an exhaustive match here.
412 pub const ALL: &'static [FailureCode] = &[
413 FailureCode::ElementNotFound,
414 FailureCode::NotVisible,
415 FailureCode::NotEnabled,
416 FailureCode::Ambiguous,
417 FailureCode::Timeout,
418 FailureCode::AssertionFailed,
419 FailureCode::AppNotRunning,
420 FailureCode::SimulatorNotBooted,
421 FailureCode::TapMissed,
422 FailureCode::CoordinateSpaceMismatch,
423 FailureCode::DriverError,
424 FailureCode::CaptureBackpressure,
425 FailureCode::AppNotInForeground,
426 ];
427
428 /// Whether the target may be outside the app's tree altogether — on a
429 /// system popup or a notification banner — so those are worth a look.
430 pub fn may_be_outside_the_app(self) -> bool {
431 match self {
432 FailureCode::ElementNotFound
433 | FailureCode::NotVisible
434 | FailureCode::AppNotInForeground => true,
435 FailureCode::NotEnabled
436 | FailureCode::Ambiguous
437 | FailureCode::Timeout
438 | FailureCode::AssertionFailed
439 | FailureCode::AppNotRunning
440 | FailureCode::SimulatorNotBooted
441 | FailureCode::TapMissed
442 | FailureCode::CoordinateSpaceMismatch
443 | FailureCode::DriverError
444 | FailureCode::CaptureBackpressure => false,
445 }
446 }
447
448 /// Whether this code is a verdict about the screen, as opposed to
449 /// smix being unable to look at it. An `optional` step skips only a
450 /// verdict, and an end-to-end check judges a flow by its own rule only
451 /// on one — a runner answering garbage once read as "the scroll stopped
452 /// in the wrong place". Exhaustive, so a new code must pick a side.
453 pub fn judges_the_screen(self) -> bool {
454 match self {
455 FailureCode::ElementNotFound
456 | FailureCode::NotVisible
457 | FailureCode::NotEnabled
458 | FailureCode::Ambiguous
459 | FailureCode::Timeout
460 | FailureCode::AssertionFailed
461 | FailureCode::TapMissed
462 | FailureCode::CoordinateSpaceMismatch => true,
463 FailureCode::AppNotRunning
464 | FailureCode::SimulatorNotBooted
465 | FailureCode::DriverError
466 | FailureCode::CaptureBackpressure
467 | FailureCode::AppNotInForeground => false,
468 }
469 }
470}
471
472#[cfg(test)]
473mod what_a_code_is_about {
474 use super::*;
475
476 /// The eight codes that say something about the screen, and the five
477 /// that say smix could not look. Named rather than counted: a code
478 /// moved from one side to the other has to be moved here too.
479 #[test]
480 fn a_verdict_is_about_the_screen_and_a_driver_failure_is_not() {
481 let about_the_screen = [
482 FailureCode::ElementNotFound,
483 FailureCode::NotVisible,
484 FailureCode::NotEnabled,
485 FailureCode::Ambiguous,
486 FailureCode::Timeout,
487 FailureCode::AssertionFailed,
488 FailureCode::TapMissed,
489 FailureCode::CoordinateSpaceMismatch,
490 ];
491 let could_not_look = [
492 FailureCode::AppNotRunning,
493 FailureCode::SimulatorNotBooted,
494 FailureCode::DriverError,
495 FailureCode::CaptureBackpressure,
496 FailureCode::AppNotInForeground,
497 ];
498 for c in about_the_screen {
499 assert!(c.judges_the_screen(), "{c:?} is a verdict about the screen");
500 }
501 for c in could_not_look {
502 assert!(
503 !c.judges_the_screen(),
504 "{c:?} says smix could not look, not what it saw"
505 );
506 }
507 assert_eq!(
508 about_the_screen.len() + could_not_look.len(),
509 FailureCode::ALL.len(),
510 "every code is on one side or the other"
511 );
512 }
513}
514
515#[cfg(test)]
516mod vocabulary {
517 use super::*;
518
519 /// A new `FailureCode` must not compile until it has been thought
520 /// about everywhere.
521 ///
522 /// This match is exhaustive on purpose and lives inside the crate on
523 /// purpose: `#[non_exhaustive]` makes an outside `match` accept
524 /// anything, so the guard that used to sit in
525 /// `tests/it/sdk_failure_code_parity.rs` would have started passing for
526 /// every future variant. Adding a code should break this line, then
527 /// the wire-string arm below it, then the three SDK declarations the
528 /// parity test reads, then the errors guide.
529 #[test]
530 fn the_vocabulary_is_pinned() {
531 let every = FailureCode::ALL;
532 for &code in every {
533 match code {
534 FailureCode::ElementNotFound
535 | FailureCode::NotVisible
536 | FailureCode::NotEnabled
537 | FailureCode::Ambiguous
538 | FailureCode::Timeout
539 | FailureCode::AssertionFailed
540 | FailureCode::AppNotRunning
541 | FailureCode::SimulatorNotBooted
542 | FailureCode::TapMissed
543 | FailureCode::CoordinateSpaceMismatch
544 | FailureCode::DriverError
545 | FailureCode::CaptureBackpressure
546 | FailureCode::AppNotInForeground => {}
547 }
548 // Every code renders as a wire string, and a code whose
549 // string nobody wrote would reach a consumer as whatever
550 // the formatter defaults to.
551 assert!(!format_code(code).is_empty(), "{code:?} has no wire string");
552 }
553 assert_eq!(
554 every.len(),
555 13,
556 "counted off the list above, not remembered"
557 );
558 }
559}