Skip to main content

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        }
289        if let Some(h) = &self.hint {
290            lines.push(format!("  hint: {}", h));
291        }
292        if !self.device_log.is_empty() {
293            // LOG_PROMPT_CAP — defensive ceiling for AI prompt size.
294            const LOG_PROMPT_CAP: usize = 200;
295            let n = self.device_log.len();
296            lines.push(format!("  device log (last {} lines):", n));
297            let start = n.saturating_sub(LOG_PROMPT_CAP);
298            for dl in &self.device_log[start..] {
299                lines.push(format!("    - {}", dl));
300            }
301        }
302        lines.join("\n")
303    }
304}
305
306impl fmt::Display for ExpectationFailure {
307    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308        write!(f, "{}", self.to_prompt())
309    }
310}
311
312impl std::error::Error for ExpectationFailure {}
313
314/// One line naming the windows a failure's screen held.
315///
316/// Repeats collapse (`×2`) because a status bar and a navigation bar are
317/// both `com.android.systemui`, and listing it twice says nothing the
318/// count does not. `None` when there is nothing to say — a tree that
319/// carries no window information gets no line rather than an empty one.
320fn on_screen_line(windows: &[WindowInfo], unreadable: Option<u32>) -> Option<String> {
321    let mut parts: Vec<(String, usize)> = Vec::new();
322    for w in windows {
323        let who = w.package.as_deref().unwrap_or("(package not reported)");
324        let kind = match w.kind {
325            WindowKind::Application => "application",
326            WindowKind::InputMethod => "keyboard",
327            WindowKind::System => "system",
328            WindowKind::Other => "other",
329        };
330        let label = if w.focused {
331            format!("{who} ({kind}, focused)")
332        } else {
333            format!("{who} ({kind})")
334        };
335        match parts.last_mut() {
336            Some((last, n)) if *last == label => *n += 1,
337            _ => parts.push((label, 1)),
338        }
339    }
340    let mut bits: Vec<String> = parts
341        .into_iter()
342        .map(|(l, n)| if n > 1 { format!("{l} ×{n}") } else { l })
343        .collect();
344    match unreadable {
345        Some(1) => bits.push("1 window unreadable".into()),
346        Some(n) if n > 1 => bits.push(format!("{n} windows unreadable")),
347        _ => {}
348    }
349    (!bits.is_empty()).then(|| format!("  on screen: {}", bits.join(" · ")))
350}
351
352fn format_code(c: FailureCode) -> &'static str {
353    match c {
354        FailureCode::ElementNotFound => "ELEMENT_NOT_FOUND",
355        FailureCode::NotVisible => "NOT_VISIBLE",
356        FailureCode::NotEnabled => "NOT_ENABLED",
357        FailureCode::Ambiguous => "AMBIGUOUS",
358        FailureCode::Timeout => "TIMEOUT",
359        FailureCode::AssertionFailed => "ASSERTION_FAILED",
360        FailureCode::AppNotRunning => "APP_NOT_RUNNING",
361        FailureCode::SimulatorNotBooted => "SIMULATOR_NOT_BOOTED",
362        FailureCode::TapMissed => "TAP_MISSED",
363        FailureCode::CoordinateSpaceMismatch => "COORDINATE_SPACE_MISMATCH",
364        FailureCode::DriverError => "DRIVER_ERROR",
365        FailureCode::CaptureBackpressure => "CAPTURE_BACKPRESSURE",
366        FailureCode::AppNotInForeground => "APP_NOT_IN_FOREGROUND",
367    }
368}
369
370fn render_element(el: &ElementSummary) -> String {
371    let role_str = el.role.map(|r| r.as_str()).unwrap_or("unknown");
372    let mut bits: Vec<String> = vec![role_str.to_string()];
373    if let Some(n) = &el.name {
374        bits.push(format!("name={:?}", n));
375    }
376    if let Some(i) = &el.id {
377        bits.push(format!("id=\"{}\"", i));
378    }
379    if let Some(t) = &el.text
380        && Some(t) != el.name.as_ref()
381    {
382        bits.push(format!("text={:?}", t));
383    }
384    if !el.enabled {
385        bits.push("disabled".into());
386    }
387    bits.join(" ")
388}
389
390// -------------------- buildSuggestions ----------------------------------
391
392const SUGGESTION_THRESHOLD: f64 = 0.5;
393const SUGGESTION_TOP_N: usize = 3;
394
395/// Generate "Did you mean ...?" suggestions from the current visible
396/// element list. Contract: threshold > 0.5, top 3, score desc →
397/// field (name > text) → DFS index asc.
398///
399/// `target = None` → empty vec.
400#[must_use]
401pub fn build_suggestions(target: Option<&str>, visible: &[ElementSummary]) -> Vec<String> {
402    let Some(target) = target else {
403        return Vec::new();
404    };
405    let lower_target = target.to_lowercase();
406    let mut candidates: Vec<(f64, &'static str, String, usize)> = Vec::new();
407    for (i, el) in visible.iter().enumerate() {
408        let mut best: Option<(f64, &'static str, String)> = None;
409        if let Some(name) = &el.name
410            && !name.is_empty()
411        {
412            let s = similarity(&name.to_lowercase(), &lower_target);
413            if s > SUGGESTION_THRESHOLD {
414                best = Some((s, "name", name.clone()));
415            }
416        }
417        if let Some(id) = &el.id
418            && !id.is_empty()
419        {
420            let s = similarity(&id.to_lowercase(), &lower_target);
421            if s > SUGGESTION_THRESHOLD && best.as_ref().map(|(bs, _, _)| s > *bs).unwrap_or(true) {
422                best = Some((s, "id", id.clone()));
423            }
424        }
425        if let Some(text) = &el.text
426            && !text.is_empty()
427        {
428            let s = similarity(&text.to_lowercase(), &lower_target);
429            if s > SUGGESTION_THRESHOLD && best.as_ref().map(|(bs, _, _)| s > *bs).unwrap_or(true) {
430                best = Some((s, "text", text.clone()));
431            }
432        }
433        if let Some((score, field, value)) = best {
434            candidates.push((score, field, value, i));
435        }
436    }
437    // Sort: score desc → field name>text → index asc.
438    let field_rank = |f: &str| match f {
439        "name" => 0,
440        "id" => 1,
441        _ => 2, // text
442    };
443    candidates.sort_by(|a, b| {
444        b.0.partial_cmp(&a.0)
445            .unwrap_or(std::cmp::Ordering::Equal)
446            .then_with(|| field_rank(a.1).cmp(&field_rank(b.1)))
447            .then_with(|| a.3.cmp(&b.3))
448    });
449    candidates
450        .into_iter()
451        .take(SUGGESTION_TOP_N)
452        .map(|(score, field, value, _)| {
453            format!(
454                "Did you mean {:?}? (similarity {:.2}, field {})",
455                value, score, field
456            )
457        })
458        .collect()
459}
460
461/// Normalized `[0, 1]` string similarity. 1 = identical,
462/// 0 = completely different.
463#[must_use]
464pub fn similarity(a: &str, b: &str) -> f64 {
465    if a == b {
466        return 1.0;
467    }
468    let (longer, shorter) = if a.chars().count() >= b.chars().count() {
469        (a, b)
470    } else {
471        (b, a)
472    };
473    let llen = longer.chars().count();
474    if llen == 0 {
475        return 1.0;
476    }
477    let dist = edit_distance(longer, shorter);
478    (llen as f64 - dist as f64) / llen as f64
479}
480
481/// Levenshtein edit distance (Wagner-Fischer).
482#[must_use]
483pub fn edit_distance(a: &str, b: &str) -> usize {
484    let a_chars: Vec<char> = a.chars().collect();
485    let b_chars: Vec<char> = b.chars().collect();
486    let a_len = a_chars.len();
487    let b_len = b_chars.len();
488    let mut dp: Vec<usize> = (0..=b_len).collect();
489    for i in 1..=a_len {
490        let mut prev = dp[0];
491        dp[0] = i;
492        for j in 1..=b_len {
493            let tmp = dp[j];
494            dp[j] = if a_chars[i - 1] == b_chars[j - 1] {
495                prev
496            } else {
497                1 + dp[j].min(dp[j - 1]).min(prev)
498            };
499            prev = tmp;
500        }
501    }
502    dp[b_len]
503}
504
505impl FailureCode {
506    /// Every code, once.
507    ///
508    /// The one list: the SDK parity test and the errors guide read it
509    /// rather than keeping their own (a copy outside this crate let
510    /// `CaptureBackpressure` ship in 7.1 with no SDK declaring it, since
511    /// `#[non_exhaustive]` makes an outside `match` accept anything).
512    /// `the_vocabulary_is_pinned` guards it with an exhaustive match here.
513    pub const ALL: &'static [FailureCode] = &[
514        FailureCode::ElementNotFound,
515        FailureCode::NotVisible,
516        FailureCode::NotEnabled,
517        FailureCode::Ambiguous,
518        FailureCode::Timeout,
519        FailureCode::AssertionFailed,
520        FailureCode::AppNotRunning,
521        FailureCode::SimulatorNotBooted,
522        FailureCode::TapMissed,
523        FailureCode::CoordinateSpaceMismatch,
524        FailureCode::DriverError,
525        FailureCode::CaptureBackpressure,
526        FailureCode::AppNotInForeground,
527    ];
528
529    /// Whether the target may be outside the app's tree altogether — on a
530    /// system popup or a notification banner — so those are worth a look.
531    pub fn may_be_outside_the_app(self) -> bool {
532        match self {
533            FailureCode::ElementNotFound
534            | FailureCode::NotVisible
535            | FailureCode::AppNotInForeground => true,
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 => false,
546        }
547    }
548
549    /// Whether this code is a verdict about the screen, as opposed to
550    /// smix being unable to look at it. An `optional` step skips only a
551    /// verdict, and an end-to-end check judges a flow by its own rule only
552    /// on one — a runner answering garbage once read as "the scroll stopped
553    /// in the wrong place". Exhaustive, so a new code must pick a side.
554    pub fn judges_the_screen(self) -> bool {
555        match self {
556            FailureCode::ElementNotFound
557            | FailureCode::NotVisible
558            | FailureCode::NotEnabled
559            | FailureCode::Ambiguous
560            | FailureCode::Timeout
561            | FailureCode::AssertionFailed
562            | FailureCode::TapMissed
563            | FailureCode::CoordinateSpaceMismatch => true,
564            FailureCode::AppNotRunning
565            | FailureCode::SimulatorNotBooted
566            | FailureCode::DriverError
567            | FailureCode::CaptureBackpressure
568            | FailureCode::AppNotInForeground => false,
569        }
570    }
571}
572
573#[cfg(test)]
574mod what_a_code_is_about {
575    use super::*;
576
577    /// The eight codes that say something about the screen, and the five
578    /// that say smix could not look. Named rather than counted: a code
579    /// moved from one side to the other has to be moved here too.
580    #[test]
581    fn a_verdict_is_about_the_screen_and_a_driver_failure_is_not() {
582        let about_the_screen = [
583            FailureCode::ElementNotFound,
584            FailureCode::NotVisible,
585            FailureCode::NotEnabled,
586            FailureCode::Ambiguous,
587            FailureCode::Timeout,
588            FailureCode::AssertionFailed,
589            FailureCode::TapMissed,
590            FailureCode::CoordinateSpaceMismatch,
591        ];
592        let could_not_look = [
593            FailureCode::AppNotRunning,
594            FailureCode::SimulatorNotBooted,
595            FailureCode::DriverError,
596            FailureCode::CaptureBackpressure,
597            FailureCode::AppNotInForeground,
598        ];
599        for c in about_the_screen {
600            assert!(c.judges_the_screen(), "{c:?} is a verdict about the screen");
601        }
602        for c in could_not_look {
603            assert!(
604                !c.judges_the_screen(),
605                "{c:?} says smix could not look, not what it saw"
606            );
607        }
608        assert_eq!(
609            about_the_screen.len() + could_not_look.len(),
610            FailureCode::ALL.len(),
611            "every code is on one side or the other"
612        );
613    }
614}
615
616#[cfg(test)]
617mod vocabulary {
618    use super::*;
619
620    /// A new `FailureCode` must not compile until it has been thought
621    /// about everywhere.
622    ///
623    /// This match is exhaustive on purpose and lives inside the crate on
624    /// purpose: `#[non_exhaustive]` makes an outside `match` accept
625    /// anything, so the guard that used to sit in
626    /// `tests/it/sdk_failure_code_parity.rs` would have started passing for
627    /// every future variant. Adding a code should break this line, then
628    /// the wire-string arm below it, then the three SDK declarations the
629    /// parity test reads, then the errors guide.
630    #[test]
631    fn the_vocabulary_is_pinned() {
632        let every = FailureCode::ALL;
633        for &code in every {
634            match code {
635                FailureCode::ElementNotFound
636                | FailureCode::NotVisible
637                | FailureCode::NotEnabled
638                | FailureCode::Ambiguous
639                | FailureCode::Timeout
640                | FailureCode::AssertionFailed
641                | FailureCode::AppNotRunning
642                | FailureCode::SimulatorNotBooted
643                | FailureCode::TapMissed
644                | FailureCode::CoordinateSpaceMismatch
645                | FailureCode::DriverError
646                | FailureCode::CaptureBackpressure
647                | FailureCode::AppNotInForeground => {}
648            }
649            // Every code renders as a wire string, and a code whose
650            // string nobody wrote would reach a consumer as whatever
651            // the formatter defaults to.
652            assert!(!format_code(code).is_empty(), "{code:?} has no wire string");
653        }
654        assert_eq!(
655            every.len(),
656            13,
657            "counted off the list above, not remembered"
658        );
659    }
660}