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