Skip to main content

tui_test/diagnostics/
failure.rs

1use serde::{Deserialize, Serialize};
2
3use super::strings::truncate_diagnostic_value;
4use super::FailureObservation;
5use super::{
6    CellMismatch, ComparisonDiagnostics, FailureReason, FailureReport, LocatorFailureReason,
7    FAILURE_SCHEMA_VERSION,
8};
9use crate::api::{ErrorKind, TextPosition, TuiTestError};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct FailureDetails {
13    pub schema_version: u32,
14    pub operation: String,
15    pub reason: FailureReason,
16    pub summary: String,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub locator: Option<LocatorFailure>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub comparison: Option<ComparisonDiagnostics>,
21    pub truncated: bool,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct LocatorFailure {
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub reason: Option<LocatorFailureReason>,
28    /// Selector descriptions leading to the failing stage.
29    pub selectors: Vec<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub stage_index: Option<usize>,
32    /// Sample candidate starts in terminal grid coordinates.
33    pub locations: Vec<TextPosition>,
34    pub mismatches: Vec<CellMismatch>,
35}
36
37impl FailureDetails {
38    pub fn new(
39        operation: impl AsRef<str>,
40        reason: FailureReason,
41        summary: impl AsRef<str>,
42    ) -> Self {
43        let mut truncated = false;
44        Self {
45            schema_version: FAILURE_SCHEMA_VERSION,
46            operation: bounded(operation.as_ref(), 256, &mut truncated),
47            reason,
48            summary: bounded(summary.as_ref(), 4096, &mut truncated),
49            locator: None,
50            comparison: None,
51            truncated,
52        }
53    }
54}
55
56impl FailureReport {
57    pub(crate) fn failure_details(&self) -> FailureDetails {
58        let mut details = FailureDetails::new(&self.operation.name, self.reason, &self.summary);
59        let truncated = &mut details.truncated;
60        *truncated |= self.truncated;
61        details.comparison = self
62            .comparison
63            .as_ref()
64            .map(|comparison| bounded_comparison(comparison, 1024, truncated));
65        if let Some(comparison) = &details.comparison {
66            if comparison.kind == "snapshot" {
67                if let (Some(expected), Some(actual)) = (&comparison.expected, &comparison.actual) {
68                    details.summary = format!(
69                        "snapshot mismatch\n--- expected ---\n{expected}\n--- actual ---\n{actual}"
70                    );
71                }
72            }
73        }
74        details.locator = self.locator.as_ref().map(|locator| {
75            let stage = locator
76                .failure_stage
77                .and_then(|index| {
78                    locator
79                        .stages
80                        .iter()
81                        .find(|stage| stage.stage_index == index)
82                })
83                .or_else(|| locator.stages.last());
84            let candidates = stage.map_or(locator.selected.as_slice(), |stage| &stage.candidates);
85            let mismatches = stage.map_or(&[][..], |stage| stage.mismatches.as_slice());
86            *truncated |= candidates.len() > 4
87                || mismatches.len() > 4
88                || stage
89                    .is_some_and(|stage| stage.candidates_truncated || stage.mismatches_truncated);
90            let mut selectors = Vec::new();
91            for entry in &locator.stages {
92                if let Some(selector) = &entry.selector {
93                    if selectors.len() == 8 {
94                        *truncated = true;
95                        break;
96                    }
97                    selectors.push(bounded(&selector.description(), 256, truncated));
98                }
99                if stage.is_some_and(|stage| entry.stage_index == stage.stage_index) {
100                    break;
101                }
102            }
103            LocatorFailure {
104                reason: locator.failure_reason,
105                selectors,
106                stage_index: locator.failure_stage,
107                locations: candidates
108                    .iter()
109                    .take(4)
110                    .map(|candidate| candidate.start)
111                    .collect(),
112                mismatches: mismatches
113                    .iter()
114                    .take(4)
115                    .map(|mismatch| CellMismatch {
116                        location: mismatch.location,
117                        grapheme: bounded(&mismatch.grapheme, 64, truncated),
118                        property: bounded(&mismatch.property, 64, truncated),
119                        operator: bounded(&mismatch.operator, 64, truncated),
120                        expected: bounded(&mismatch.expected, 256, truncated),
121                        actual: bounded(&mismatch.actual, 256, truncated),
122                        resolved: mismatch
123                            .resolved
124                            .as_ref()
125                            .map(|value| bounded(value, 256, truncated)),
126                        reason: bounded(&mismatch.reason, 256, truncated),
127                    })
128                    .collect(),
129            }
130        });
131        details
132    }
133}
134
135fn bounded(value: &str, limit: usize, truncated: &mut bool) -> String {
136    if value.len() <= limit {
137        return value.to_string();
138    }
139    *truncated = true;
140    let mut end = limit;
141    while !value.is_char_boundary(end) {
142        end -= 1;
143    }
144    format!("{}...", &value[..end])
145}
146
147fn bounded_comparison(
148    comparison: &ComparisonDiagnostics,
149    limit: usize,
150    truncated: &mut bool,
151) -> ComparisonDiagnostics {
152    let mut start = 0;
153    if comparison.kind == "snapshot" {
154        if let (Some(expected), Some(actual)) = (&comparison.expected, &comparison.actual) {
155            if expected.len() > limit || actual.len() > limit {
156                let common = expected
157                    .chars()
158                    .zip(actual.chars())
159                    .take_while(|(expected, actual)| expected == actual)
160                    .map(|(ch, _)| ch.len_utf8())
161                    .sum::<usize>();
162                // Keep the differing line, or a little context if that line alone is long.
163                start = expected[..common]
164                    .rfind('\n')
165                    .map_or(0, |index| index + 1)
166                    .max(common.saturating_sub(limit / 4));
167                while !expected.is_char_boundary(start) {
168                    start -= 1;
169                }
170            }
171        }
172    }
173    let mut excerpt = |value: &String| {
174        if start != 0 {
175            *truncated = true;
176            format!("...{}", bounded(&value[start..], limit - 3, truncated))
177        } else {
178            bounded(value, limit, truncated)
179        }
180    };
181    let expected = comparison.expected.as_ref().map(&mut excerpt);
182    let actual = comparison.actual.as_ref().map(&mut excerpt);
183    ComparisonDiagnostics {
184        kind: bounded(&comparison.kind, 128, truncated),
185        expected,
186        actual,
187    }
188}
189
190pub(crate) fn failure_reason(
191    error: &TuiTestError,
192    observation: Option<&FailureObservation>,
193) -> FailureReason {
194    let reason = error.report.as_ref().map_or_else(
195        || match error.kind {
196            ErrorKind::Internal => FailureReason::InternalFailure,
197            ErrorKind::Assertion if error.message.starts_with("session exited") => {
198                FailureReason::SessionExited
199            }
200            ErrorKind::Assertion
201                if error.message.contains("timed out") || error.message.contains("timeout") =>
202            {
203                FailureReason::TimedOut
204            }
205            ErrorKind::Assertion if error.message.contains("snapshot mismatch") => {
206                FailureReason::SnapshotMismatch
207            }
208            ErrorKind::Assertion => FailureReason::ScalarMismatch,
209            ErrorKind::Usage | ErrorKind::NoSession => FailureReason::InternalFailure,
210        },
211        |report| report.reason,
212    );
213    if matches!(
214        reason,
215        FailureReason::TimedOut
216            | FailureReason::SessionExited
217            | FailureReason::LocatorNoMatch
218            | FailureReason::LocatorAmbiguous
219            | FailureReason::UnexpectedMatch
220            | FailureReason::MatchNotActionable
221    ) {
222        if let Some(observation) = observation {
223            if observation.process.cancelled {
224                return FailureReason::Cancelled;
225            }
226            if observation.process.exit_code.is_some() {
227                return FailureReason::SessionExited;
228            }
229        }
230    }
231    reason
232}
233
234pub(crate) fn merge_failure_details(target: &mut FailureReport, source: FailureReport) {
235    let (summary, truncated) = truncate_diagnostic_value(source.summary, 64 * 1024);
236    target.summary = summary;
237    target.locator = source.locator;
238    target.comparison = source.comparison;
239    target.evaluation_transitions = source.evaluation_transitions;
240    target.hints = source.hints;
241    target.truncated |= source.truncated || truncated;
242    if source.operation.timeout_ms.is_some() {
243        target.operation.timeout_ms = source.operation.timeout_ms;
244    }
245}
246
247pub(crate) fn comparison_failure(
248    operation: &str,
249    timeout_ms: Option<u64>,
250    reason: FailureReason,
251    message: String,
252    kind: &str,
253    expected: Option<String>,
254    actual: Option<String>,
255) -> TuiTestError {
256    let mut details = FailureReport::new(operation, timeout_ms, reason, message.clone());
257    details.comparison = Some(bounded_comparison(
258        &ComparisonDiagnostics {
259            kind: kind.to_string(),
260            expected,
261            actual,
262        },
263        256 * 1024,
264        &mut details.truncated,
265    ));
266    TuiTestError::assertion(message).with_report(details)
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn snapshot_excerpts_preserve_late_differences_through_both_limits() {
275        for prefix in [
276            "same row\n".repeat(40),
277            "same row\n".repeat(40_000),
278            "\u{1f600}".repeat(80_000),
279        ] {
280            let expected = format!("{prefix}EXPECTED_DIFFERENCE{}", " tail".repeat(400));
281            let actual = format!("{prefix}OBSERVED_DIFFERENCE{}", " tail".repeat(400));
282            let error = comparison_failure(
283                "expect.snapshot",
284                None,
285                FailureReason::SnapshotMismatch,
286                format!(
287                    "snapshot mismatch\n--- expected ---\n{expected}\n--- actual ---\n{actual}"
288                ),
289                "snapshot",
290                Some(expected),
291                Some(actual),
292            );
293            let report = error.report.unwrap();
294            let comparison = report.comparison.as_ref().unwrap();
295            assert!(comparison
296                .expected
297                .as_ref()
298                .unwrap()
299                .contains("EXPECTED_DIFFERENCE"));
300            assert!(comparison
301                .actual
302                .as_ref()
303                .unwrap()
304                .contains("OBSERVED_DIFFERENCE"));
305            let details = report.failure_details();
306            let comparison = details.comparison.as_ref().unwrap();
307            assert_ne!(comparison.expected, comparison.actual);
308            assert!(comparison.expected.as_ref().unwrap().len() <= 1027);
309            assert!(comparison.actual.as_ref().unwrap().len() <= 1027);
310            assert!(details.summary.contains("EXPECTED_DIFFERENCE"));
311            assert!(details.summary.contains("OBSERVED_DIFFERENCE"));
312            assert!(details.summary.len() <= 4096);
313            assert!(details.truncated);
314        }
315    }
316
317    #[test]
318    fn comparison_excerpts_handle_end_of_input_and_unicode_boundaries() {
319        let prefix = "\u{1f600}".repeat(2000);
320        for (expected, actual) in [
321            (String::new(), "new".into()),
322            ("short".into(), "other".into()),
323            (prefix.clone(), format!("{prefix}extra")),
324            (format!("{prefix}extra"), prefix.clone()),
325            (format!("{prefix}\u{e9}"), format!("{prefix}\u{ea}")),
326            (format!("{prefix}\n"), format!("{prefix}\nextra")),
327        ] {
328            let comparison = ComparisonDiagnostics {
329                kind: "snapshot".into(),
330                expected: Some(expected.clone()),
331                actual: Some(actual.clone()),
332            };
333            let mut truncated = false;
334            let bounded = bounded_comparison(&comparison, 1024, &mut truncated);
335            assert_ne!(bounded.expected, bounded.actual);
336            if expected.len() <= 1024 && actual.len() <= 1024 {
337                assert_eq!(bounded, comparison);
338                assert!(!truncated);
339            } else {
340                assert!(truncated);
341            }
342        }
343        let comparison = ComparisonDiagnostics {
344            kind: "exit_code".into(),
345            expected: Some("0".into()),
346            actual: None,
347        };
348        assert_eq!(
349            bounded_comparison(&comparison, 1024, &mut false),
350            comparison
351        );
352    }
353
354    #[test]
355    fn public_failure_contains_only_bounded_actionable_evidence() {
356        let mut report = FailureReport::new(
357            "expect.output",
358            Some(25),
359            FailureReason::ScalarMismatch,
360            "failure".repeat(10_000),
361        );
362        report.comparison = Some(ComparisonDiagnostics {
363            kind: "output".into(),
364            expected: Some("expected".repeat(10_000)),
365            actual: Some("observed".repeat(10_000)),
366        });
367        report
368            .context
369            .insert("private".into(), "report only".into());
370        report.finish_signature();
371        let details = report.failure_details();
372        let json = serde_json::to_value(&details).unwrap();
373        let keys: Vec<_> = json
374            .as_object()
375            .unwrap()
376            .keys()
377            .map(String::as_str)
378            .collect();
379        assert_eq!(
380            keys,
381            [
382                "comparison",
383                "operation",
384                "reason",
385                "schema_version",
386                "summary",
387                "truncated"
388            ]
389        );
390        assert!(serde_json::to_vec(&details).unwrap().len() < 8192);
391        assert!(details.truncated);
392        assert_eq!(details.operation, "expect.output");
393        assert_eq!(
394            report
395                .comparison
396                .as_ref()
397                .unwrap()
398                .actual
399                .as_ref()
400                .unwrap()
401                .len(),
402            80_000
403        );
404        assert_eq!(
405            serde_json::from_value::<FailureDetails>(json).unwrap(),
406            details
407        );
408    }
409}