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 pub selectors: Vec<String>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub stage_index: Option<usize>,
32 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 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}