1use std::collections::{BTreeMap, VecDeque};
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::api::{
12 AutomaticRecordingMode, LocatorDirection, LocatorSelector, MatchOccurrence, Size, TextMatch,
13 TextPosition,
14};
15use crate::render::svg::RenderState;
16use crate::terminal::cell::EmuCell;
17use crate::terminal::emu::CursorShape;
18
19mod expectation;
20mod failure;
21mod html;
22mod input;
23mod markdown;
24pub(crate) mod strings;
25#[cfg(test)]
26mod test_fixture;
27pub use expectation::{LocatorExpectation, OperationExpectation};
28pub(crate) use failure::{comparison_failure, failure_reason, merge_failure_details};
29pub use failure::{FailureDetails, LocatorFailure};
30pub use input::{InputArguments, InputDetails, MouseTarget};
31
32pub const FAILURE_SCHEMA_VERSION: u32 = 1;
33pub const DEFAULT_SCREEN_HISTORY_LIMIT: u16 = 10;
34pub const MAX_SCREEN_HISTORY_LIMIT: u16 = 50;
35
36const FAILURE_JSON_LIMIT: usize = 2 * 1024 * 1024;
37const REPORT_LIMIT: usize = 1024 * 1024;
38const TIMELINE_LIMIT: usize = 8 * 1024 * 1024;
39const HTML_LIMIT: usize = 128 * 1024 * 1024;
40const SCREEN_TEXT_LIMIT: usize = 1024 * 1024;
41const SCREEN_SVG_LIMIT: usize = 8 * 1024 * 1024;
42pub(crate) const RECORDING_COPY_LIMIT: u64 = 64 * 1024 * 1024;
43const ARTIFACT_TOTAL_LIMIT: u64 = 256 * 1024 * 1024;
44const MAX_HISTORY_BYTES: usize = 512 * 1024;
45const MAX_CHECKPOINT_BYTES: usize = 8 * 1024 * 1024;
46const MAX_CONTEXT_ENTRIES: usize = 16;
47const MAX_CONTEXT_KEY_BYTES: usize = 64;
48const MAX_CONTEXT_VALUE_BYTES: usize = 256;
49const MAX_CANDIDATES: usize = 64;
50const MAX_MISMATCHES: usize = 64;
51const MAX_OPERATION_HISTORY: usize = 32;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum FailureReason {
56 TimedOut,
57 SessionExited,
58 Cancelled,
59 LocatorNoMatch,
60 LocatorAmbiguous,
61 UnexpectedMatch,
62 MatchNotActionable,
63 ScalarMismatch,
64 SnapshotMismatch,
65 EmulatorFault,
66 InternalFailure,
67 Completed,
68 TestFailed,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum LocatorFailureReason {
74 AnchorNotFound,
75 AnchorAmbiguous,
76 RelativeRegionNoMatch,
77 StyleFilterRemovedAll,
78 LinkFilterRemovedAll,
79 IntersectionEmpty,
80 UnionEmpty,
81 FilterRemovedAll,
82 NthOutOfRange,
83 OutsideViewport,
84 MatchedNoCells,
85 NoMatch,
86 Ambiguous,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum OccurrenceSource {
92 Explicit,
93 ActionDefault,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum LocatorStageMode {
99 Text,
100 ContiguousStyleRuns,
101 ParentStyleFilter,
102 ContiguousLinkRuns,
103 ParentLinkFilter,
104 Intersection,
105 Union,
106 ContainmentFilter,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct OperationDiagnostics {
111 pub name: String,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub timeout_ms: Option<u64>,
114 pub elapsed_ms: u64,
115 pub started_screen_sequence: u64,
116 pub failed_screen_sequence: u64,
117}
118
119impl OperationDiagnostics {
120 pub fn pending(name: impl Into<String>, timeout_ms: Option<u64>) -> Self {
121 Self {
122 name: name.into(),
123 timeout_ms,
124 elapsed_ms: 0,
125 started_screen_sequence: 0,
126 failed_screen_sequence: 0,
127 }
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct LocatorStageDiagnostics {
133 pub stage_index: usize,
134 #[serde(default)]
135 pub expression_path: String,
136 #[serde(default)]
137 pub evaluations: usize,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub failure_reason: Option<LocatorFailureReason>,
140 pub mode: LocatorStageMode,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub selector: Option<LocatorSelector>,
143 pub direction: LocatorDirection,
144 pub requested_occurrence: MatchOccurrence,
145 pub effective_occurrence: MatchOccurrence,
147 pub occurrence_source: OccurrenceSource,
148 pub input_candidate_count: usize,
149 pub raw_candidate_count: usize,
150 pub style_candidate_count: usize,
152 pub selected_count: usize,
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 pub candidates: Vec<TextMatch>,
155 pub candidates_truncated: bool,
156 #[serde(default, skip_serializing_if = "Vec::is_empty")]
157 pub mismatches: Vec<CellMismatch>,
158 pub mismatches_truncated: bool,
159}
160
161impl LocatorStageDiagnostics {
162 pub(crate) fn truncate(&mut self) {
163 if self.candidates.len() > MAX_CANDIDATES {
164 self.candidates.truncate(MAX_CANDIDATES);
165 self.candidates_truncated = true;
166 }
167 if self.mismatches.len() > MAX_MISMATCHES {
168 self.mismatches.truncate(MAX_MISMATCHES);
169 self.mismatches_truncated = true;
170 }
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct CellMismatch {
176 pub location: TextPosition,
177 pub grapheme: String,
178 pub property: String,
179 pub operator: String,
180 pub expected: String,
181 pub actual: String,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pub resolved: Option<String>,
184 pub reason: String,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub(crate) struct CellStyleEvaluation {
189 pub matched: bool,
190 pub mismatches: Vec<CellMismatch>,
191 pub mismatches_truncated: bool,
192}
193
194impl CellStyleEvaluation {
195 pub(crate) fn reject(&mut self, limit: usize, capture: impl FnOnce() -> CellMismatch) -> bool {
196 self.matched = false;
197 if self.mismatches.len() == limit {
198 self.mismatches_truncated = true;
199 return false;
200 }
201 self.mismatches.push(capture());
202 true
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207pub struct LocatorDiagnostics {
208 pub search_scope: String,
210 pub viewport_origin_y: u32,
212 pub stages: Vec<LocatorStageDiagnostics>,
213 pub final_candidate_count: usize,
215 #[serde(default)]
216 pub stages_truncated: bool,
217 #[serde(skip_serializing_if = "Option::is_none")]
218 pub evaluation_error: Option<String>,
219 #[serde(default, skip_serializing_if = "Vec::is_empty")]
220 pub selected: Vec<TextMatch>,
221 #[serde(skip_serializing_if = "Option::is_none")]
222 pub failure_stage: Option<usize>,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub failure_reason: Option<LocatorFailureReason>,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct EvaluationTransition {
229 pub elapsed_ms: u64,
230 pub screen_sequence: u64,
231 pub outcome: String,
232 #[serde(skip_serializing_if = "Option::is_none")]
233 pub stage_index: Option<usize>,
234 pub stage_counts: Vec<usize>,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238pub struct OperationEvent {
239 pub sequence: u64,
240 pub name: String,
241 pub started_ms: u64,
242 pub ended_ms: u64,
243 pub result: String,
244 pub screen_before: u64,
245 pub screen_at_return: u64,
246 pub safe_summary: String,
247 #[serde(default)]
248 pub is_assertion: bool,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub expectation: Option<OperationExpectation>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub input: Option<InputDetails>,
253}
254
255#[derive(Debug)]
256pub(crate) struct OperationHistory {
257 next_sequence: u64,
258 generation: u64,
259 entries: VecDeque<OperationEvent>,
260}
261
262#[derive(Debug)]
263pub(crate) struct PendingOperation {
264 sequence: u64,
265 name: String,
266 started_at: Instant,
267 started_ms: u64,
268 screen_before: u64,
269 safe_summary: String,
270 is_assertion: bool,
271 expectation: Option<OperationExpectation>,
272 generation: u64,
273}
274
275impl PendingOperation {
276 pub(crate) fn sequence(&self) -> u64 {
277 self.sequence
278 }
279}
280
281impl OperationHistory {
282 pub(crate) fn new() -> Self {
283 Self {
284 next_sequence: 1,
285 generation: 0,
286 entries: VecDeque::new(),
287 }
288 }
289
290 pub(crate) fn begin(
291 &mut self,
292 name: String,
293 started_ms: u64,
294 screen_before: u64,
295 safe_summary: String,
296 is_assertion: bool,
297 expectation: Option<OperationExpectation>,
298 ) -> PendingOperation {
299 let sequence = self.next_sequence;
300 self.next_sequence = self.next_sequence.wrapping_add(1).max(1);
301 PendingOperation {
302 sequence,
303 name,
304 started_at: Instant::now(),
305 started_ms,
306 screen_before,
307 safe_summary,
308 is_assertion,
309 expectation,
310 generation: self.generation,
311 }
312 }
313
314 pub(crate) fn reset_session(&mut self) {
315 self.entries.clear();
316 self.generation = self.generation.wrapping_add(1);
317 }
318
319 pub(crate) fn finish(
320 &mut self,
321 pending: PendingOperation,
322 ended_ms: Option<u64>,
323 screen_at_return: u64,
324 result: impl Into<String>,
325 input: Option<InputDetails>,
326 ) {
327 let started_ms = if pending.generation == self.generation {
328 pending.started_ms
329 } else {
330 0
331 };
332 self.entries.push_back(OperationEvent {
333 sequence: pending.sequence,
334 name: pending.name,
335 started_ms,
336 ended_ms: ended_ms
337 .unwrap_or_else(|| started_ms.saturating_add(elapsed_ms(pending.started_at))),
338 result: result.into(),
339 screen_before: if pending.generation == self.generation {
340 pending.screen_before
341 } else {
342 0
343 },
344 screen_at_return,
345 safe_summary: pending.safe_summary,
346 is_assertion: pending.is_assertion,
347 expectation: pending.expectation,
348 input,
349 });
350 while self.entries.len() > MAX_OPERATION_HISTORY {
351 self.entries.pop_front();
352 }
353 }
354
355 pub(crate) fn snapshot(&self) -> Vec<OperationEvent> {
356 self.entries.iter().cloned().collect()
357 }
358}
359
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
361pub struct CursorDiagnostics {
362 pub column: u16,
363 pub row: u16,
364 pub visible: bool,
365 pub shape: String,
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369pub struct ScreenSnapshotDetails {
370 pub sequence: u64,
371 pub first_seen_ms: u64,
372 pub last_seen_ms: u64,
373 pub repeat_count: u64,
374 pub changes: Vec<String>,
375 pub size: Size,
376 pub cursor: CursorDiagnostics,
377 #[serde(skip_serializing_if = "Option::is_none")]
378 pub title: Option<String>,
379 pub text: String,
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383pub struct ScreenHistoryDetails {
384 pub limit: u16,
385 pub dropped_screen_count: u64,
386 pub dropped_row_count: u64,
387 #[serde(default)]
388 pub dropped_checkpoint_count: u64,
389 pub screens: Vec<ScreenSnapshotDetails>,
390 #[serde(default, skip_serializing_if = "Vec::is_empty")]
391 pub checkpoints: Vec<ScreenSnapshotDetails>,
392}
393
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct TerminalDiagnostics {
396 pub size: Size,
397 #[serde(skip_serializing_if = "Option::is_none")]
398 pub title: Option<String>,
399 pub cursor: CursorDiagnostics,
400 pub last_visual_change_ms: u64,
401 pub unchanged_for_ms: u64,
402 pub screen_history: ScreenHistoryDetails,
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406pub struct ProcessDiagnostics {
407 #[serde(skip_serializing_if = "Option::is_none")]
408 pub pid: Option<u32>,
409 pub state: String,
410 #[serde(skip_serializing_if = "Option::is_none")]
411 pub exit_code: Option<i32>,
412 #[serde(skip_serializing_if = "Option::is_none")]
413 pub status_error: Option<String>,
414 pub cancelled: bool,
415 pub ready: bool,
416 pub command_running: bool,
417 #[serde(skip_serializing_if = "Option::is_none")]
418 pub last_command_exit: Option<i32>,
419}
420
421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422pub struct RuntimeDiagnostics {
423 #[serde(skip_serializing_if = "Option::is_none")]
424 pub session_name: Option<String>,
425 #[serde(skip_serializing_if = "Option::is_none")]
426 pub shell: Option<String>,
427 #[serde(skip_serializing_if = "Option::is_none")]
428 pub timeouts: Option<crate::api::EffectiveTimeouts>,
429 pub tui_test_version: String,
430 pub backend: String,
431 pub target_os: String,
432 pub target_arch: String,
433}
434
435#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
436#[serde(rename_all = "snake_case")]
437pub enum RecordingStatus {
438 Disabled,
439 Unavailable,
440 Live,
441 Copied,
442 Omitted,
443 Failed,
444}
445
446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
447pub struct RecordingDiagnostics {
448 pub mode: AutomaticRecordingMode,
449 pub status: RecordingStatus,
450 pub failure_offset_ms: u64,
451 #[serde(skip_serializing_if = "Option::is_none")]
452 pub last_committed_ms: Option<u64>,
453 #[serde(skip_serializing_if = "Option::is_none")]
454 pub path: Option<String>,
455 #[serde(skip_serializing_if = "Option::is_none")]
456 pub bytes: Option<u64>,
457 #[serde(skip_serializing_if = "Option::is_none")]
458 pub reason: Option<String>,
459 pub ephemeral: bool,
460}
461
462#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
463pub struct DiagnosticHint {
464 pub code: String,
465 pub message: String,
466}
467
468#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
469pub struct ComparisonDiagnostics {
470 pub kind: String,
471 #[serde(skip_serializing_if = "Option::is_none")]
472 pub expected: Option<String>,
473 #[serde(skip_serializing_if = "Option::is_none")]
474 pub actual: Option<String>,
475}
476
477#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
478pub struct FailureReport {
479 pub schema_version: u32,
480 pub signature: String,
482 pub operation: OperationDiagnostics,
483 pub reason: FailureReason,
484 pub summary: String,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub outcome: Option<TraceOutcome>,
487 #[serde(skip_serializing_if = "Option::is_none")]
488 pub locator: Option<LocatorDiagnostics>,
489 #[serde(skip_serializing_if = "Option::is_none")]
490 pub comparison: Option<ComparisonDiagnostics>,
491 #[serde(default, skip_serializing_if = "Vec::is_empty")]
492 pub evaluation_transitions: Vec<EvaluationTransition>,
493 #[serde(default, skip_serializing_if = "Vec::is_empty")]
494 pub recent_operations: Vec<OperationEvent>,
495 #[serde(skip_serializing_if = "Option::is_none")]
496 pub terminal: Option<TerminalDiagnostics>,
497 #[serde(skip_serializing_if = "Option::is_none")]
498 pub process: Option<ProcessDiagnostics>,
499 #[serde(skip_serializing_if = "Option::is_none")]
500 pub runtime: Option<RuntimeDiagnostics>,
501 #[serde(skip_serializing_if = "Option::is_none")]
502 pub recording: Option<RecordingDiagnostics>,
503 #[serde(default, skip_serializing_if = "Vec::is_empty")]
504 pub hints: Vec<DiagnosticHint>,
505 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
506 pub context: BTreeMap<String, String>,
507 pub truncated: bool,
508}
509
510impl FailureReport {
511 pub(crate) fn artifact_name(&self, extension: &str) -> String {
512 format!(
513 "{}.{}",
514 if self.outcome.is_some() {
515 "trace"
516 } else {
517 "failure"
518 },
519 extension
520 )
521 }
522
523 pub fn new(
524 operation: impl Into<String>,
525 timeout_ms: Option<u64>,
526 reason: FailureReason,
527 summary: impl Into<String>,
528 ) -> Self {
529 Self {
530 schema_version: FAILURE_SCHEMA_VERSION,
531 signature: String::new(),
532 operation: OperationDiagnostics::pending(operation, timeout_ms),
533 reason,
534 summary: summary.into(),
535 outcome: None,
536 locator: None,
537 comparison: None,
538 evaluation_transitions: Vec::new(),
539 recent_operations: Vec::new(),
540 terminal: None,
541 process: None,
542 runtime: None,
543 recording: None,
544 hints: Vec::new(),
545 context: BTreeMap::new(),
546 truncated: false,
547 }
548 }
549
550 pub(crate) fn finish_signature(&mut self) {
551 let mut hasher = Sha256::new();
552 hasher.update(self.operation.name.as_bytes());
553 hasher.update([self.reason as u8]);
554 if let Some(locator) = &self.locator {
555 hasher.update(locator.search_scope.as_bytes());
556 for stage in &locator.stages {
557 hasher.update([stage.mode as u8, stage.direction as u8]);
558 hasher.update(format!("{:?}", stage.effective_occurrence).as_bytes());
559 }
560 if let Some(reason) = locator.failure_reason {
561 hasher.update([reason as u8]);
562 }
563 }
564 if let Some(runtime) = &self.runtime {
565 hasher.update(runtime.backend.as_bytes());
566 }
567 self.signature = format!("sha256:{:x}", hasher.finalize());
568 }
569}
570
571#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
572#[serde(rename_all = "snake_case")]
573pub enum FailureArtifactMode {
574 #[default]
575 All,
576 Html,
577 Text,
578 None,
579}
580
581#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
582#[serde(default, deny_unknown_fields)]
583pub struct FailureArtifactOptions {
584 pub directory: PathBuf,
585 pub mode: FailureArtifactMode,
586 pub include_recording: bool,
587}
588
589impl Default for FailureArtifactOptions {
590 fn default() -> Self {
591 Self {
592 directory: PathBuf::new(),
593 mode: FailureArtifactMode::All,
594 include_recording: false,
595 }
596 }
597}
598
599impl FailureArtifactOptions {
600 pub fn validate(&self) -> Result<(), String> {
601 if self.mode != FailureArtifactMode::None && self.directory.as_os_str().is_empty() {
602 return Err("failure artifact directory must not be empty".to_string());
603 }
604 Ok(())
605 }
606
607 pub(crate) fn wants_text(&self) -> bool {
608 matches!(
609 self.mode,
610 FailureArtifactMode::All | FailureArtifactMode::Text
611 )
612 }
613
614 pub(crate) fn wants_svg(&self) -> bool {
615 matches!(self.mode, FailureArtifactMode::All)
616 }
617
618 pub(crate) fn wants_json(&self) -> bool {
619 matches!(
620 self.mode,
621 FailureArtifactMode::All | FailureArtifactMode::Text
622 )
623 }
624
625 pub(crate) fn wants_markdown(&self) -> bool {
626 matches!(
627 self.mode,
628 FailureArtifactMode::All | FailureArtifactMode::Text
629 )
630 }
631
632 pub(crate) fn wants_html(&self) -> bool {
633 matches!(
634 self.mode,
635 FailureArtifactMode::All | FailureArtifactMode::Html
636 )
637 }
638}
639
640#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
641#[serde(default, deny_unknown_fields)]
642pub struct ExecutionContext {
643 pub operation_name: Option<String>,
644 pub artifact: Option<FailureArtifactOptions>,
645 pub diagnostic_context: BTreeMap<String, String>,
646 pub retention: DiagnosticRetentionOptions,
647 pub trace: Option<TraceOptions>,
648}
649
650#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
651#[serde(rename_all = "kebab-case")]
652pub enum TraceMode {
653 #[default]
654 Off,
655 On,
656 OnFailure,
657}
658
659#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
660#[serde(rename_all = "snake_case")]
661pub enum TraceOutcome {
662 Passed,
663 Failed,
664}
665
666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
667#[serde(default, deny_unknown_fields)]
668pub struct TraceOptions {
669 pub mode: TraceMode,
670 pub directory: PathBuf,
671}
672
673impl Default for TraceOptions {
674 fn default() -> Self {
675 Self {
676 mode: TraceMode::Off,
677 directory: PathBuf::from(".tui-test/traces"),
678 }
679 }
680}
681
682impl TraceOptions {
683 pub fn validate(&self) -> Result<(), String> {
684 if self.directory.as_os_str().is_empty() {
685 return Err("trace directory must not be empty".into());
686 }
687 Ok(())
688 }
689
690 pub(crate) fn artifact_options(&self) -> FailureArtifactOptions {
691 FailureArtifactOptions {
692 directory: self.directory.clone(),
693 mode: FailureArtifactMode::All,
694 include_recording: true,
695 }
696 }
697}
698
699impl ExecutionContext {
700 pub fn with_operation(mut self, operation_name: impl Into<String>) -> Self {
701 self.operation_name = Some(operation_name.into());
702 self
703 }
704
705 pub fn sanitized_context(&self) -> BTreeMap<String, String> {
706 self.diagnostic_context
707 .iter()
708 .take(MAX_CONTEXT_ENTRIES)
709 .map(|(key, value)| {
710 let key = truncate_utf8(key, MAX_CONTEXT_KEY_BYTES);
711 let value = if value.len() <= MAX_CONTEXT_VALUE_BYTES {
712 value.clone()
713 } else {
714 format!("{}...", truncate_utf8(value, MAX_CONTEXT_VALUE_BYTES))
715 };
716 (key, value)
717 })
718 .collect()
719 }
720}
721
722fn truncate_utf8(value: &str, limit: usize) -> String {
723 if value.len() <= limit {
724 return value.to_string();
725 }
726 let mut end = limit;
727 while !value.is_char_boundary(end) {
728 end -= 1;
729 }
730 value[..end].to_string()
731}
732
733#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
734#[serde(default, deny_unknown_fields)]
735pub struct DiagnosticRetentionOptions {
736 #[serde(alias = "screen-history-limit")]
737 pub screen_history_limit: u16,
738}
739
740impl Default for DiagnosticRetentionOptions {
741 fn default() -> Self {
742 Self {
743 screen_history_limit: DEFAULT_SCREEN_HISTORY_LIMIT,
744 }
745 }
746}
747
748impl DiagnosticRetentionOptions {
749 pub fn validate(&self) -> Result<(), String> {
750 if self.screen_history_limit > MAX_SCREEN_HISTORY_LIMIT {
751 return Err(format!(
752 "screen history limit must be at most {MAX_SCREEN_HISTORY_LIMIT}"
753 ));
754 }
755 Ok(())
756 }
757}
758
759#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
760#[serde(rename_all = "snake_case")]
761pub enum FailureArtifactStatus {
762 Written,
763 Partial,
764 Failed,
765}
766
767#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
768pub struct FailureArtifactRef {
769 pub status: FailureArtifactStatus,
770 pub directory: String,
771 #[serde(skip_serializing_if = "Option::is_none")]
772 pub manifest: Option<String>,
773 #[serde(skip_serializing_if = "Option::is_none")]
774 pub report: Option<String>,
775 #[serde(skip_serializing_if = "Option::is_none")]
776 pub report_html: Option<String>,
777 #[serde(skip_serializing_if = "Option::is_none")]
778 pub timeline: Option<String>,
779 #[serde(skip_serializing_if = "Option::is_none")]
780 pub screen_text: Option<String>,
781 #[serde(skip_serializing_if = "Option::is_none")]
782 pub screen_svg: Option<String>,
783 #[serde(skip_serializing_if = "Option::is_none")]
784 pub recording: Option<String>,
785 #[serde(default, skip_serializing_if = "Vec::is_empty")]
786 pub errors: Vec<String>,
787}
788
789#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
790#[serde(rename_all = "snake_case")]
791pub enum ArtifactFileStatus {
792 Written,
793 Omitted,
794 Failed,
795}
796
797#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
798pub struct ArtifactFile {
799 pub kind: String,
800 pub path: String,
801 pub status: ArtifactFileStatus,
802 #[serde(skip_serializing_if = "Option::is_none")]
803 pub bytes: Option<u64>,
804 #[serde(skip_serializing_if = "Option::is_none")]
805 pub sha256: Option<String>,
806 #[serde(skip_serializing_if = "Option::is_none")]
807 pub reason: Option<String>,
808}
809
810#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
811pub struct SensitivityDetails {
812 #[serde(default)]
813 pub contains_input: bool,
814 pub contains_locator_operands: bool,
815 pub contains_terminal_output: bool,
816 pub contains_terminal_title: bool,
817 pub contains_visual_output: bool,
818 pub contains_recording_output: bool,
819 pub contains_assertion_operands: bool,
820 pub contains_snapshot_evidence: bool,
821 pub contains_diagnostic_context: bool,
822 pub contains_user_supplied_values: bool,
823 pub permissions: String,
824}
825
826#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
827pub struct FailureArtifactManifest {
828 #[serde(flatten)]
829 pub details: FailureReport,
830 pub sensitivity: SensitivityDetails,
831 pub files: Vec<ArtifactFile>,
832 #[serde(default, skip_serializing_if = "Vec::is_empty")]
833 pub errors: Vec<String>,
834}
835
836#[derive(Debug, Clone)]
837pub(crate) struct FailureObservation {
838 pub rows: Vec<Vec<EmuCell>>,
839 pub cols: u16,
840 pub title: Option<String>,
841 pub cursor: Option<(u16, usize)>,
842 pub cursor_position: (u16, u16),
843 pub cursor_visible: bool,
844 pub cursor_shape: CursorShape,
845 pub render_state: RenderState,
846 pub screen_sequence: u64,
847 pub output_revision: u64,
848 pub captured_ms: u64,
849 pub last_visual_change_ms: u64,
850 pub history: ScreenHistory,
851 pub process: ProcessDiagnostics,
852 pub runtime: RuntimeDiagnostics,
853}
854
855impl FailureObservation {
856 pub(crate) fn text(&self) -> String {
857 crate::assert::snapshot::serialize(&self.rows, self.cols, false, self.title.as_deref())
858 }
859
860 pub(crate) fn svg(&self) -> String {
861 crate::render::svg::render_svg_with_zoom(
862 &self.rows,
863 self.cols,
864 &self.render_state,
865 self.cursor,
866 self.title.as_deref(),
867 1.0,
868 None,
869 )
870 }
871
872 pub(crate) fn terminal(&self) -> TerminalDiagnostics {
873 TerminalDiagnostics {
874 size: Size {
875 cols: self.cols,
876 rows: self.rows.len().min(u16::MAX as usize) as u16,
877 },
878 title: self.title.clone(),
879 cursor: CursorDiagnostics {
880 column: self.cursor_position.0,
881 row: self.cursor_position.1,
882 visible: self.cursor_visible,
883 shape: cursor_shape_name(self.cursor_shape).to_string(),
884 },
885 last_visual_change_ms: self.last_visual_change_ms,
886 unchanged_for_ms: self.captured_ms.saturating_sub(self.last_visual_change_ms),
887 screen_history: self.history.snapshot(),
888 }
889 }
890}
891
892pub(crate) fn cursor_shape_name(shape: CursorShape) -> &'static str {
893 match shape {
894 CursorShape::Block => "block",
895 CursorShape::Underline => "underline",
896 CursorShape::Bar => "bar",
897 }
898}
899
900#[derive(Debug, Clone)]
901pub(crate) struct ScreenHistory {
902 limit: u16,
903 dropped_screen_count: u64,
904 dropped_row_count: u64,
905 next_sequence: u64,
906 entries: VecDeque<std::sync::Arc<ScreenFrame>>,
907 checkpoints: VecDeque<std::sync::Arc<ScreenFrame>>,
908 checkpoint_bytes: usize,
909 dropped_checkpoint_count: u64,
910 bytes: usize,
911}
912
913#[derive(Debug, Clone)]
914pub(crate) struct ScreenFrame {
915 details: ScreenSnapshotDetails,
916 rows: std::sync::Arc<Vec<Vec<EmuCell>>>,
917 render_state: RenderState,
918}
919
920impl ScreenHistory {
921 pub(crate) fn new(limit: u16) -> Self {
922 Self {
923 limit: limit.min(MAX_SCREEN_HISTORY_LIMIT),
924 dropped_screen_count: 0,
925 dropped_row_count: 0,
926 next_sequence: 1,
927 entries: VecDeque::new(),
928 checkpoints: VecDeque::new(),
929 checkpoint_bytes: 0,
930 dropped_checkpoint_count: 0,
931 bytes: 0,
932 }
933 }
934
935 #[allow(clippy::too_many_arguments)]
936 pub(crate) fn capture(
937 &mut self,
938 rows: Vec<Vec<EmuCell>>,
939 cols: u16,
940 title: Option<String>,
941 cursor: (u16, u16),
942 cursor_visible: bool,
943 cursor_shape: CursorShape,
944 elapsed_ms: u64,
945 render_state: RenderState,
946 ) -> u64 {
947 let text = crate::assert::snapshot::serialize(&rows, cols, false, title.as_deref());
948 if let Some(last) = self.entries.back_mut() {
949 if last.rows.as_ref() == &rows
950 && last.details.title == title
951 && last.details.size.cols == cols
952 && last.details.size.rows == rows.len().min(u16::MAX as usize) as u16
953 && last.details.cursor.column == cursor.0
954 && last.details.cursor.row == cursor.1
955 && last.details.cursor.visible == cursor_visible
956 && last.details.cursor.shape == cursor_shape_name(cursor_shape)
957 && last.render_state == render_state
958 {
959 return self.observe_current(elapsed_ms);
960 }
961 }
962
963 let mut changes = match self.entries.back() {
964 None => vec!["initial".to_string()],
965 Some(previous) => screen_changes(
966 &previous.rows,
967 &rows,
968 &previous.details,
969 &title,
970 cols,
971 cursor,
972 cursor_visible,
973 cursor_shape,
974 ),
975 };
976 if self
977 .entries
978 .back()
979 .is_some_and(|previous| previous.render_state != render_state)
980 {
981 changes.push("palette".to_string());
982 }
983 let sequence = self.next_sequence;
984 self.next_sequence = self.next_sequence.wrapping_add(1).max(1);
985 let details = ScreenSnapshotDetails {
986 sequence,
987 first_seen_ms: elapsed_ms,
988 last_seen_ms: elapsed_ms,
989 repeat_count: 1,
990 changes,
991 size: Size {
992 cols,
993 rows: rows.len().min(u16::MAX as usize) as u16,
994 },
995 cursor: CursorDiagnostics {
996 column: cursor.0,
997 row: cursor.1,
998 visible: cursor_visible,
999 shape: cursor_shape_name(cursor_shape).to_string(),
1000 },
1001 title,
1002 text,
1003 };
1004 self.bytes = self
1005 .bytes
1006 .saturating_add(estimate_screen_bytes(&details, &rows));
1007 self.entries.push_back(std::sync::Arc::new(ScreenFrame {
1008 details,
1009 rows: std::sync::Arc::new(rows),
1010 render_state,
1011 }));
1012 self.evict();
1013 sequence
1014 }
1015
1016 fn evict(&mut self) {
1017 while self.entries.len() > usize::from(self.limit.max(1))
1018 || (self.bytes > MAX_HISTORY_BYTES && self.entries.len() > 1)
1019 {
1020 let Some(entry) = self.entries.pop_front() else {
1021 break;
1022 };
1023 self.bytes = self
1024 .bytes
1025 .saturating_sub(estimate_screen_bytes(&entry.details, &entry.rows));
1026 self.dropped_screen_count = self.dropped_screen_count.saturating_add(1);
1027 self.dropped_row_count = self
1028 .dropped_row_count
1029 .saturating_add(entry.rows.len() as u64);
1030 }
1031 }
1032
1033 pub(crate) fn current_sequence(&self) -> u64 {
1034 self.entries
1035 .back()
1036 .map_or(0, |entry| entry.details.sequence)
1037 }
1038
1039 pub(crate) fn observe_current(&mut self, elapsed_ms: u64) -> u64 {
1040 let Some(last) = self.entries.back_mut() else {
1041 return 0;
1042 };
1043 let last = std::sync::Arc::make_mut(last);
1044 last.details.last_seen_ms = elapsed_ms;
1045 last.details.repeat_count = last.details.repeat_count.saturating_add(1);
1046 last.details.sequence
1047 }
1048
1049 pub(crate) fn snapshot(&self) -> ScreenHistoryDetails {
1050 ScreenHistoryDetails {
1051 limit: self.limit,
1052 dropped_screen_count: self.dropped_screen_count,
1053 dropped_row_count: self.dropped_row_count,
1054 dropped_checkpoint_count: self.dropped_checkpoint_count,
1055 screens: self
1056 .entries
1057 .iter()
1058 .map(|entry| entry.details.clone())
1059 .collect(),
1060 checkpoints: self
1061 .checkpoints
1062 .iter()
1063 .map(|entry| entry.details.clone())
1064 .collect(),
1065 }
1066 }
1067
1068 pub(crate) fn pin_current(&mut self) {
1069 let Some(frame) = self.entries.back() else {
1070 return;
1071 };
1072 if self
1073 .checkpoints
1074 .back()
1075 .is_some_and(|last| last.details.sequence == frame.details.sequence)
1076 {
1077 return;
1078 }
1079 let bytes = estimate_screen_bytes(&frame.details, &frame.rows);
1080 if self.limit == 0 || bytes > MAX_CHECKPOINT_BYTES {
1081 self.dropped_checkpoint_count = self.dropped_checkpoint_count.saturating_add(1);
1082 return;
1083 }
1084 self.checkpoint_bytes += bytes;
1085 self.checkpoints.push_back(frame.clone());
1086 while self.checkpoints.len() > MAX_OPERATION_HISTORY
1087 || self.checkpoint_bytes > MAX_CHECKPOINT_BYTES
1088 {
1089 if let Some(old) = self.checkpoints.pop_front() {
1090 self.checkpoint_bytes -= estimate_screen_bytes(&old.details, &old.rows);
1091 self.dropped_checkpoint_count = self.dropped_checkpoint_count.saturating_add(1);
1092 }
1093 }
1094 }
1095
1096 fn retained(&self) -> BTreeMap<u64, &std::sync::Arc<ScreenFrame>> {
1097 self.checkpoints
1098 .iter()
1099 .chain(&self.entries)
1100 .map(|frame| (frame.details.sequence, frame))
1101 .collect()
1102 }
1103
1104 #[cfg(test)]
1105 pub(crate) fn frames(&self) -> Vec<std::sync::Arc<ScreenFrame>> {
1106 self.retained()
1107 .values()
1108 .map(|frame| (*frame).clone())
1109 .collect()
1110 }
1111}
1112
1113fn estimate_screen_bytes(details: &ScreenSnapshotDetails, rows: &[Vec<EmuCell>]) -> usize {
1114 details.text.len()
1115 + std::mem::size_of::<RenderState>()
1116 + details.title.as_ref().map_or(0, String::len)
1117 + rows
1118 .iter()
1119 .flatten()
1120 .map(|cell| {
1121 cell.ch.len()
1122 + std::mem::size_of::<EmuCell>()
1123 + cell.hyperlink.as_ref().map_or(0, |link| {
1124 link.uri.len() + link.id.as_ref().map_or(0, |id| id.len())
1125 })
1126 })
1127 .sum::<usize>()
1128}
1129
1130#[allow(clippy::too_many_arguments)]
1131fn screen_changes(
1132 previous_rows: &[Vec<EmuCell>],
1133 rows: &[Vec<EmuCell>],
1134 previous: &ScreenSnapshotDetails,
1135 title: &Option<String>,
1136 cols: u16,
1137 cursor: (u16, u16),
1138 cursor_visible: bool,
1139 cursor_shape: CursorShape,
1140) -> Vec<String> {
1141 let mut changes = Vec::new();
1142 if previous_rows != rows {
1143 let text_changed = previous_rows.len() != rows.len()
1144 || previous_rows.iter().zip(rows).any(|(previous_row, row)| {
1145 previous_row.len() != row.len()
1146 || previous_row
1147 .iter()
1148 .zip(row)
1149 .any(|(previous_cell, cell)| previous_cell.ch != cell.ch)
1150 });
1151 let style_changed = previous_rows.iter().zip(rows).any(|(previous_row, row)| {
1152 previous_row.iter().zip(row).any(|(previous_cell, cell)| {
1153 previous_cell.fg != cell.fg
1154 || previous_cell.bg != cell.bg
1155 || previous_cell.underline != cell.underline
1156 || previous_cell.underline_color != cell.underline_color
1157 || previous_cell.attrs != cell.attrs
1158 })
1159 });
1160 if text_changed {
1161 changes.push("text".to_string());
1162 }
1163 if style_changed {
1164 changes.push("style".to_string());
1165 }
1166 }
1167 if previous.title != *title {
1168 changes.push("title".to_string());
1169 }
1170 if previous.size.cols != cols || previous.size.rows != rows.len().min(u16::MAX as usize) as u16
1171 {
1172 changes.push("size".to_string());
1173 }
1174 if previous.cursor.column != cursor.0
1175 || previous.cursor.row != cursor.1
1176 || previous.cursor.visible != cursor_visible
1177 || previous.cursor.shape != cursor_shape_name(cursor_shape)
1178 {
1179 changes.push("cursor".to_string());
1180 }
1181 if changes.is_empty() {
1182 changes.push("visual".to_string());
1183 }
1184 changes
1185}
1186
1187pub(crate) struct ArtifactInputs<'a> {
1188 pub details: &'a mut FailureReport,
1189 pub observation: &'a FailureObservation,
1190 pub recording: Option<PreparedRecording>,
1191}
1192
1193#[derive(Debug, Clone)]
1194pub(crate) struct PreparedRecording {
1195 pub temporary_path: PathBuf,
1196 pub bytes: u64,
1197 pub sha256: String,
1198}
1199
1200pub(crate) fn allocate_artifact_directory(base: &Path) -> io::Result<PathBuf> {
1201 allocate_directory(base, "failure")
1202}
1203
1204pub(crate) fn allocate_trace_directory(base: &Path) -> io::Result<PathBuf> {
1205 allocate_directory(base, "trace")
1206}
1207
1208fn allocate_directory(base: &Path, prefix: &str) -> io::Result<PathBuf> {
1209 static SEQUENCE: AtomicU64 = AtomicU64::new(0);
1210 fs::create_dir_all(base)?;
1211 let epoch_ms = SystemTime::now()
1212 .duration_since(UNIX_EPOCH)
1213 .unwrap_or(Duration::ZERO)
1214 .as_millis();
1215 for _ in 0..100 {
1216 let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
1217 let path = base.join(format!(
1218 "{prefix}-{epoch_ms}-p{}-{sequence}",
1219 std::process::id()
1220 ));
1221 match fs::create_dir(&path) {
1222 Ok(()) => {
1223 #[cfg(unix)]
1224 {
1225 use std::os::unix::fs::PermissionsExt;
1226 fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?;
1227 }
1228 return Ok(path);
1229 }
1230 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
1231 Err(error) => return Err(error),
1232 }
1233 }
1234 Err(io::Error::new(
1235 io::ErrorKind::AlreadyExists,
1236 "failed to allocate a unique diagnostic directory",
1237 ))
1238}
1239
1240pub(crate) fn recording_temp_path(directory: &Path) -> PathBuf {
1241 directory.join("session.cast.tmp")
1242}
1243
1244pub(crate) fn write_failure_artifact(
1245 options: &FailureArtifactOptions,
1246 inputs: ArtifactInputs<'_>,
1247 directory: PathBuf,
1248) -> FailureArtifactRef {
1249 let mut reference = FailureArtifactRef {
1250 status: FailureArtifactStatus::Failed,
1251 directory: directory.to_string_lossy().into_owned(),
1252 manifest: None,
1253 report: None,
1254 report_html: None,
1255 timeline: None,
1256 screen_text: None,
1257 screen_svg: None,
1258 recording: None,
1259 errors: Vec::new(),
1260 };
1261 if options.mode == FailureArtifactMode::None {
1262 return reference;
1263 }
1264
1265 let markdown_name = inputs.details.artifact_name("md");
1266 let html_name = inputs.details.artifact_name("html");
1267 let manifest_name = inputs.details.artifact_name("json");
1268 let mut files = Vec::new();
1269 let mut total = 0u64;
1270 if options.wants_text() {
1271 let screen_text = inputs.observation.text();
1272 write_optional_file(
1273 &directory,
1274 "screen_text",
1275 "current.txt",
1276 screen_text.as_bytes(),
1277 SCREEN_TEXT_LIMIT as u64,
1278 &mut total,
1279 &mut files,
1280 &mut reference.errors,
1281 );
1282 if files
1283 .last()
1284 .is_some_and(|file| file.status == ArtifactFileStatus::Written)
1285 {
1286 reference.screen_text =
1287 Some(directory.join("current.txt").to_string_lossy().into_owned());
1288 }
1289 }
1290
1291 if options.wants_svg() {
1292 let cell_count = inputs.observation.rows.iter().map(Vec::len).sum::<usize>();
1293 if cell_count > 100_000 {
1294 files.push(ArtifactFile {
1295 kind: "screen_svg".to_string(),
1296 path: "current.svg".to_string(),
1297 status: ArtifactFileStatus::Omitted,
1298 bytes: None,
1299 sha256: None,
1300 reason: Some("size_limit".to_string()),
1301 });
1302 } else {
1303 let svg = inputs.observation.svg();
1304 write_optional_file(
1305 &directory,
1306 "screen_svg",
1307 "current.svg",
1308 svg.as_bytes(),
1309 SCREEN_SVG_LIMIT as u64,
1310 &mut total,
1311 &mut files,
1312 &mut reference.errors,
1313 );
1314 }
1315 if files
1316 .last()
1317 .is_some_and(|file| file.status == ArtifactFileStatus::Written)
1318 {
1319 reference.screen_svg =
1320 Some(directory.join("current.svg").to_string_lossy().into_owned());
1321 }
1322 }
1323
1324 if let Some(recording) = inputs.recording {
1325 let final_path = directory.join("session.cast");
1326 let status = if recording.bytes > RECORDING_COPY_LIMIT
1327 || total.saturating_add(recording.bytes) > ARTIFACT_TOTAL_LIMIT
1328 {
1329 let _ = fs::remove_file(&recording.temporary_path);
1330 if let Some(details) = inputs.details.recording.as_mut() {
1331 details.status = RecordingStatus::Omitted;
1332 details.path = None;
1333 details.reason = Some("size_limit".to_string());
1334 }
1335 ArtifactFile {
1336 kind: "recording".to_string(),
1337 path: "session.cast".to_string(),
1338 status: ArtifactFileStatus::Omitted,
1339 bytes: Some(recording.bytes),
1340 sha256: Some(recording.sha256),
1341 reason: Some("size_limit".to_string()),
1342 }
1343 } else {
1344 match fs::rename(&recording.temporary_path, &final_path) {
1345 Ok(()) => {
1346 total = total.saturating_add(recording.bytes);
1347 reference.recording = Some(final_path.to_string_lossy().into_owned());
1348 if let Some(details) = inputs.details.recording.as_mut() {
1349 details.status = RecordingStatus::Copied;
1350 details.path = Some("session.cast".to_string());
1351 details.reason = None;
1352 }
1353 ArtifactFile {
1354 kind: "recording".to_string(),
1355 path: "session.cast".to_string(),
1356 status: ArtifactFileStatus::Written,
1357 bytes: Some(recording.bytes),
1358 sha256: Some(recording.sha256),
1359 reason: None,
1360 }
1361 }
1362 Err(error) => {
1363 let _ = fs::remove_file(&recording.temporary_path);
1364 reference
1365 .errors
1366 .push(format!("failed to commit session.cast: {error}"));
1367 if let Some(details) = inputs.details.recording.as_mut() {
1368 details.status = RecordingStatus::Failed;
1369 details.path = None;
1370 details.reason = Some(error.to_string());
1371 }
1372 ArtifactFile {
1373 kind: "recording".to_string(),
1374 path: "session.cast".to_string(),
1375 status: ArtifactFileStatus::Failed,
1376 bytes: Some(recording.bytes),
1377 sha256: Some(recording.sha256),
1378 reason: Some(error.to_string()),
1379 }
1380 }
1381 }
1382 };
1383 files.push(status);
1384 } else if options.include_recording {
1385 let recording = inputs.details.recording.as_ref();
1386 let failed = recording.is_some_and(|recording| recording.status == RecordingStatus::Failed);
1387 files.push(ArtifactFile {
1388 kind: "recording".to_string(),
1389 path: "session.cast".to_string(),
1390 status: if failed {
1391 ArtifactFileStatus::Failed
1392 } else {
1393 ArtifactFileStatus::Omitted
1394 },
1395 bytes: recording.and_then(|recording| recording.bytes),
1396 sha256: None,
1397 reason: Some(
1398 recording
1399 .and_then(|recording| recording.reason.clone())
1400 .unwrap_or_else(|| "recording unavailable".to_string()),
1401 ),
1402 });
1403 }
1404
1405 if options.wants_markdown() || options.wants_html() {
1406 let generated = (|| -> io::Result<()> {
1407 let timeline = if options.wants_html() {
1408 let timeline = html::timeline(inputs.observation)?;
1409 inputs.details.truncated |= timeline.is_truncated();
1410 if options.mode == FailureArtifactMode::All {
1411 let json = serde_json::to_vec(&timeline)?;
1412 write_optional_file(
1413 &directory,
1414 "timeline",
1415 "timeline.json",
1416 &json,
1417 TIMELINE_LIMIT as u64,
1418 &mut total,
1419 &mut files,
1420 &mut reference.errors,
1421 );
1422 if files
1423 .last()
1424 .is_some_and(|file| file.status == ArtifactFileStatus::Written)
1425 {
1426 reference.timeline = Some(
1427 directory
1428 .join("timeline.json")
1429 .to_string_lossy()
1430 .into_owned(),
1431 );
1432 }
1433 }
1434 Some(timeline)
1435 } else {
1436 None
1437 };
1438 if options.wants_markdown() {
1439 let markdown = markdown::render(inputs.details, &files);
1440 write_optional_file(
1441 &directory,
1442 "report",
1443 &markdown_name,
1444 markdown.as_bytes(),
1445 REPORT_LIMIT as u64,
1446 &mut total,
1447 &mut files,
1448 &mut reference.errors,
1449 );
1450 if files
1451 .last()
1452 .is_some_and(|file| file.status == ArtifactFileStatus::Written)
1453 {
1454 reference.report = Some(
1455 directory
1456 .join(&markdown_name)
1457 .to_string_lossy()
1458 .into_owned(),
1459 );
1460 }
1461 }
1462 if let Some(timeline) = timeline {
1463 let html = html::render(
1464 inputs.details,
1465 &timeline,
1466 &files,
1467 &reference.errors,
1468 &directory,
1469 )?;
1470 write_optional_file(
1471 &directory,
1472 "report_html",
1473 &html_name,
1474 html.as_bytes(),
1475 HTML_LIMIT as u64,
1476 &mut total,
1477 &mut files,
1478 &mut reference.errors,
1479 );
1480 if files
1481 .last()
1482 .is_some_and(|file| file.status == ArtifactFileStatus::Written)
1483 {
1484 reference.report_html =
1485 Some(directory.join(&html_name).to_string_lossy().into_owned());
1486 }
1487 }
1488 Ok(())
1489 })();
1490 if let Err(error) = generated {
1491 reference.errors.push(format!(
1492 "failed to generate failure timeline/viewer: {error}"
1493 ));
1494 for (kind, path, requested) in [
1495 (
1496 "timeline",
1497 "timeline.json",
1498 options.mode == FailureArtifactMode::All,
1499 ),
1500 ("report", markdown_name.as_str(), options.wants_markdown()),
1501 ("report_html", html_name.as_str(), options.wants_html()),
1502 ] {
1503 if requested && !files.iter().any(|file| file.path == path) {
1504 files.push(ArtifactFile {
1505 kind: kind.into(),
1506 path: path.into(),
1507 status: ArtifactFileStatus::Failed,
1508 bytes: None,
1509 sha256: None,
1510 reason: Some(error.to_string()),
1511 });
1512 }
1513 }
1514 }
1515 }
1516
1517 if !options.wants_json() {
1518 reference.status = if reference.report_html.is_none() {
1519 FailureArtifactStatus::Failed
1520 } else if reference.errors.is_empty()
1521 && files
1522 .iter()
1523 .all(|file| file.status == ArtifactFileStatus::Written)
1524 {
1525 FailureArtifactStatus::Written
1526 } else {
1527 FailureArtifactStatus::Partial
1528 };
1529 return reference;
1530 }
1531
1532 let sensitivity = sensitivity(inputs.details, &files);
1533 let manifest = FailureArtifactManifest {
1534 details: inputs.details.clone(),
1535 sensitivity,
1536 files,
1537 errors: reference.errors.clone(),
1538 };
1539 let json = match serde_json::to_vec_pretty(&manifest) {
1540 Ok(json) if json.len() <= FAILURE_JSON_LIMIT => json,
1541 Ok(_) => {
1542 reference
1543 .errors
1544 .push(format!("{manifest_name} exceeded the 2 MiB limit"));
1545 return reference;
1546 }
1547 Err(error) => {
1548 reference
1549 .errors
1550 .push(format!("failed to serialize {manifest_name}: {error}"));
1551 return reference;
1552 }
1553 };
1554 if total.saturating_add(json.len() as u64) > ARTIFACT_TOTAL_LIMIT {
1555 reference.errors.push(format!(
1556 "{manifest_name} would exceed the total artifact limit"
1557 ));
1558 return reference;
1559 }
1560 let manifest_path = directory.join(&manifest_name);
1561 match write_atomic(&manifest_path, &json) {
1562 Ok(()) => {
1563 reference.manifest = Some(manifest_path.to_string_lossy().into_owned());
1564 reference.status = if reference.errors.is_empty()
1565 && manifest
1566 .files
1567 .iter()
1568 .all(|file| file.status == ArtifactFileStatus::Written)
1569 {
1570 FailureArtifactStatus::Written
1571 } else {
1572 FailureArtifactStatus::Partial
1573 };
1574 }
1575 Err(error) => reference
1576 .errors
1577 .push(format!("failed to commit {manifest_name}: {error}")),
1578 }
1579 reference
1580}
1581
1582#[allow(clippy::too_many_arguments)]
1583fn write_optional_file(
1584 directory: &Path,
1585 kind: &str,
1586 name: &str,
1587 bytes: &[u8],
1588 limit: u64,
1589 total: &mut u64,
1590 files: &mut Vec<ArtifactFile>,
1591 errors: &mut Vec<String>,
1592) {
1593 let length = bytes.len() as u64;
1594 if length > limit || total.saturating_add(length) > ARTIFACT_TOTAL_LIMIT {
1595 files.push(ArtifactFile {
1596 kind: kind.to_string(),
1597 path: name.to_string(),
1598 status: ArtifactFileStatus::Omitted,
1599 bytes: Some(length),
1600 sha256: Some(format!("sha256:{:x}", Sha256::digest(bytes))),
1601 reason: Some("size_limit".to_string()),
1602 });
1603 return;
1604 }
1605 let path = directory.join(name);
1606 match write_atomic(&path, bytes) {
1607 Ok(()) => {
1608 *total = total.saturating_add(length);
1609 files.push(ArtifactFile {
1610 kind: kind.to_string(),
1611 path: name.to_string(),
1612 status: ArtifactFileStatus::Written,
1613 bytes: Some(length),
1614 sha256: Some(format!("sha256:{:x}", Sha256::digest(bytes))),
1615 reason: None,
1616 });
1617 }
1618 Err(error) => {
1619 errors.push(format!("failed to write {name}: {error}"));
1620 files.push(ArtifactFile {
1621 kind: kind.to_string(),
1622 path: name.to_string(),
1623 status: ArtifactFileStatus::Failed,
1624 bytes: Some(length),
1625 sha256: Some(format!("sha256:{:x}", Sha256::digest(bytes))),
1626 reason: Some(error.to_string()),
1627 });
1628 }
1629 }
1630}
1631
1632fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
1633 let temporary = path.with_extension(format!(
1634 "{}.tmp",
1635 path.extension()
1636 .and_then(|value| value.to_str())
1637 .unwrap_or("")
1638 ));
1639 fs::write(&temporary, bytes)?;
1640 match fs::rename(&temporary, path) {
1641 Ok(()) => Ok(()),
1642 Err(error) => {
1643 let _ = fs::remove_file(&temporary);
1644 Err(error)
1645 }
1646 }
1647}
1648
1649fn sensitivity(details: &FailureReport, files: &[ArtifactFile]) -> SensitivityDetails {
1650 let has_recording = files
1651 .iter()
1652 .any(|file| file.kind == "recording" && file.status == ArtifactFileStatus::Written);
1653 let has_locator = details.locator.is_some();
1654 let has_terminal = details.terminal.is_some();
1655 let has_context = !details.context.is_empty();
1656 let has_expectation = details
1657 .recent_operations
1658 .iter()
1659 .any(|event| event.expectation.is_some());
1660 let has_input = details
1661 .recent_operations
1662 .iter()
1663 .any(|event| event.input.is_some());
1664 SensitivityDetails {
1665 contains_input: has_input,
1666 contains_locator_operands: has_locator,
1667 contains_terminal_output: has_terminal,
1668 contains_terminal_title: details.terminal.as_ref().is_some_and(|terminal| {
1669 terminal.title.is_some()
1670 || terminal
1671 .screen_history
1672 .screens
1673 .iter()
1674 .chain(&terminal.screen_history.checkpoints)
1675 .any(|screen| screen.title.is_some())
1676 }),
1677 contains_visual_output: files.iter().any(|file| {
1678 matches!(
1679 file.kind.as_str(),
1680 "screen_svg" | "timeline" | "report_html"
1681 ) && file.status == ArtifactFileStatus::Written
1682 }),
1683 contains_recording_output: has_recording,
1684 contains_assertion_operands: has_locator || details.comparison.is_some() || has_expectation,
1685 contains_snapshot_evidence: details.reason == FailureReason::SnapshotMismatch,
1686 contains_diagnostic_context: has_context,
1687 contains_user_supplied_values: has_locator
1688 || has_input
1689 || has_expectation
1690 || has_terminal
1691 || has_recording
1692 || has_context
1693 || details.comparison.is_some(),
1694 permissions: if cfg!(unix) {
1695 "user_only"
1696 } else {
1697 "platform_default"
1698 }
1699 .to_string(),
1700 }
1701}
1702
1703fn failure_reason_code(reason: FailureReason) -> &'static str {
1704 match reason {
1705 FailureReason::TimedOut => "timed_out",
1706 FailureReason::SessionExited => "session_exited",
1707 FailureReason::Cancelled => "cancelled",
1708 FailureReason::LocatorNoMatch => "locator_no_match",
1709 FailureReason::LocatorAmbiguous => "locator_ambiguous",
1710 FailureReason::UnexpectedMatch => "unexpected_match",
1711 FailureReason::MatchNotActionable => "match_not_actionable",
1712 FailureReason::ScalarMismatch => "scalar_mismatch",
1713 FailureReason::SnapshotMismatch => "snapshot_mismatch",
1714 FailureReason::EmulatorFault => "emulator_fault",
1715 FailureReason::InternalFailure => "internal_failure",
1716 FailureReason::Completed => "completed",
1717 FailureReason::TestFailed => "test_failed",
1718 }
1719}
1720
1721pub(crate) fn elapsed_ms(started_at: Instant) -> u64 {
1722 started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64
1723}
1724
1725#[cfg(test)]
1726mod tests {
1727 use super::*;
1728 use crate::profile::Profile;
1729 use crate::terminal::alacritty::AlacrittyEmu;
1730 use crate::terminal::cell::{Attrs, EmuCell};
1731 use crate::terminal::emu::Emulator;
1732
1733 #[test]
1734 fn context_is_bounded_without_splitting_utf8() {
1735 let mut context = ExecutionContext::default();
1736 context
1737 .diagnostic_context
1738 .insert("test".to_string(), "x".repeat(300));
1739 let values = context.sanitized_context();
1740 assert!(values["test"].ends_with("..."));
1741 assert!(values["test"].len() <= MAX_CONTEXT_VALUE_BYTES + 3);
1742 }
1743
1744 #[test]
1745 fn artifact_directories_do_not_overwrite() {
1746 let root =
1747 std::env::temp_dir().join(format!("tui-test-failure-artifact-{}", std::process::id()));
1748 let first = allocate_artifact_directory(&root).unwrap();
1749 let second = allocate_artifact_directory(&root).unwrap();
1750 assert_ne!(first, second);
1751 let _ = fs::remove_dir_all(root);
1752 }
1753
1754 #[test]
1755 fn screen_history_deduplicates_and_retains_style_changes() {
1756 let mut history = ScreenHistory::new(3);
1757 let emu = AlacrittyEmu::new(1, 1, &Profile::default());
1758 let plain = vec![vec![EmuCell::blank()]];
1759 let first = history.capture(
1760 plain.clone(),
1761 1,
1762 None,
1763 (0, 0),
1764 true,
1765 CursorShape::Block,
1766 1,
1767 RenderState::capture(&emu),
1768 );
1769 let repeated = history.capture(
1770 plain.clone(),
1771 1,
1772 None,
1773 (0, 0),
1774 true,
1775 CursorShape::Block,
1776 2,
1777 RenderState::capture(&emu),
1778 );
1779 assert_eq!(first, repeated);
1780 assert_eq!(history.snapshot().screens[0].repeat_count, 2);
1781
1782 let mut styled = plain;
1783 styled[0][0].attrs.insert(Attrs::BOLD);
1784 let second = history.capture(
1785 styled,
1786 1,
1787 None,
1788 (0, 0),
1789 true,
1790 CursorShape::Block,
1791 3,
1792 RenderState::capture(&emu),
1793 );
1794 assert_ne!(first, second);
1795 let screens = history.snapshot().screens;
1796 assert_eq!(screens.len(), 2);
1797 assert_eq!(screens[1].changes, vec!["style"]);
1798 }
1799
1800 #[test]
1801 fn observations_share_history_grids_but_freeze_frame_metadata() {
1802 use std::sync::Arc;
1803 let mut emu = AlacrittyEmu::new(80, 24, &Profile::default());
1804 let mut history = ScreenHistory::new(32);
1805 for index in 0..32 {
1806 emu.process(format!("\x1b[H{index:02}").as_bytes());
1807 history.capture(
1808 emu.viewable_rows(),
1809 80,
1810 None,
1811 emu.cursor(),
1812 false,
1813 CursorShape::Block,
1814 index,
1815 RenderState::capture(&emu),
1816 );
1817 history.pin_current();
1818 }
1819 let saved = history.clone();
1820 let frame = saved.entries.back().unwrap();
1821 assert!(Arc::ptr_eq(frame, history.entries.back().unwrap()));
1822 assert!(Arc::ptr_eq(frame, history.checkpoints.back().unwrap()));
1823 history.capture(
1824 emu.viewable_rows(),
1825 80,
1826 None,
1827 emu.cursor(),
1828 false,
1829 CursorShape::Block,
1830 100,
1831 RenderState::capture(&emu),
1832 );
1833 let latest = history.entries.back().unwrap();
1834 assert!(!Arc::ptr_eq(frame, latest));
1835 assert!(Arc::ptr_eq(&frame.rows, &latest.rows));
1836 assert_eq!(frame.details.last_seen_ms, 31);
1837 assert_eq!(latest.details.last_seen_ms, 100);
1838 emu.process(b"\x1b[HLATER");
1839 history.capture(
1840 emu.viewable_rows(),
1841 80,
1842 None,
1843 emu.cursor(),
1844 false,
1845 CursorShape::Block,
1846 101,
1847 RenderState::capture(&emu),
1848 );
1849 assert!(!saved
1850 .snapshot()
1851 .screens
1852 .last()
1853 .unwrap()
1854 .text
1855 .contains("LATER"));
1856 assert!(history
1857 .snapshot()
1858 .screens
1859 .last()
1860 .unwrap()
1861 .text
1862 .contains("LATER"));
1863 }
1864
1865 #[test]
1866 fn failure_details_round_trip() {
1867 let mut details = FailureReport::new(
1868 "locator.expect",
1869 Some(25),
1870 FailureReason::LocatorNoMatch,
1871 "missing",
1872 );
1873 details.finish_signature();
1874 let encoded = serde_json::to_string(&details).unwrap();
1875 let decoded: FailureReport = serde_json::from_str(&encoded).unwrap();
1876 assert_eq!(decoded, details);
1877 }
1878
1879 #[test]
1880 fn checkpoints_survive_sample_eviction_and_remain_bounded() {
1881 let emu = AlacrittyEmu::new(1, 1, &Profile::default());
1882 let mut history = ScreenHistory::new(1);
1883 for sequence in 1..=40 {
1884 history.capture(
1885 vec![vec![EmuCell {
1886 ch: sequence.to_string().into(),
1887 ..EmuCell::blank()
1888 }]],
1889 1,
1890 None,
1891 (0, 0),
1892 false,
1893 CursorShape::Block,
1894 sequence,
1895 RenderState::capture(&emu),
1896 );
1897 history.pin_current();
1898 }
1899 let snapshot = history.snapshot();
1900 assert_eq!(snapshot.screens.len(), 1);
1901 assert_eq!(snapshot.checkpoints.len(), MAX_OPERATION_HISTORY);
1902 assert_eq!(snapshot.dropped_screen_count, 39);
1903 assert_eq!(snapshot.dropped_checkpoint_count, 8);
1904 assert_eq!(snapshot.checkpoints[0].sequence, 9);
1905 assert!(history.checkpoint_bytes <= MAX_CHECKPOINT_BYTES);
1906 let mut disabled = ScreenHistory::new(0);
1907 for sequence in 1..=3 {
1908 disabled.capture(
1909 vec![vec![EmuCell::blank()]],
1910 1,
1911 Some(sequence.to_string()),
1912 (0, 0),
1913 false,
1914 CursorShape::Block,
1915 sequence,
1916 RenderState::capture(&emu),
1917 );
1918 disabled.pin_current();
1919 }
1920 assert_eq!(disabled.snapshot().screens.len(), 1);
1921 assert!(disabled.checkpoints.is_empty());
1922 assert_eq!(disabled.snapshot().dropped_checkpoint_count, 3);
1923
1924 let mut large = ScreenHistory::new(1);
1925 for sequence in 1..=16 {
1926 let mut rows = vec![vec![EmuCell::blank(); 450]; 50];
1927 rows[0][0].ch = sequence.to_string().into();
1928 large.capture(
1929 rows,
1930 450,
1931 None,
1932 (0, 0),
1933 false,
1934 CursorShape::Block,
1935 sequence,
1936 RenderState::capture(&emu),
1937 );
1938 large.pin_current();
1939 assert!(large.checkpoint_bytes <= MAX_CHECKPOINT_BYTES);
1940 }
1941 assert!(
1942 large.checkpoints.len() < 16,
1943 "the byte budget must evict before the count budget"
1944 );
1945 assert!(large.snapshot().dropped_checkpoint_count > 0);
1946 }
1947
1948 #[test]
1949 fn palette_only_changes_create_distinct_pinned_frames() {
1950 let mut emu = AlacrittyEmu::new(1, 1, &Profile::default());
1951 let mut history = ScreenHistory::new(1);
1952 let rows = emu.viewable_rows();
1953 history.capture(
1954 rows.clone(),
1955 1,
1956 None,
1957 (0, 0),
1958 false,
1959 CursorShape::Block,
1960 1,
1961 RenderState::capture(&emu),
1962 );
1963 history.pin_current();
1964 emu.process(b"\x1b]10;#abcdef\x07");
1965 history.capture(
1966 rows,
1967 1,
1968 None,
1969 (0, 0),
1970 false,
1971 CursorShape::Block,
1972 2,
1973 RenderState::capture(&emu),
1974 );
1975 let snapshot = history.snapshot();
1976 assert_eq!(snapshot.screens.len(), 1);
1977 assert_eq!(snapshot.checkpoints.len(), 1);
1978 assert!(snapshot.screens[0].changes.contains(&"palette".to_string()));
1979 assert_ne!(
1980 history.frames()[0].render_state,
1981 history.frames()[1].render_state
1982 );
1983 }
1984
1985 #[test]
1986 fn operation_history_does_not_link_frames_across_session_restarts() {
1987 let mut history = OperationHistory::new();
1988 let old = history.begin("old".into(), 100, 50, "old".into(), true, None);
1989 history.finish(old, Some(120), 51, "ok", None);
1990 let restart = history.begin("run".into(), 130, 51, "restart".into(), false, None);
1991 history.reset_session();
1992 history.finish(restart, Some(10), 1, "ok", None);
1993 let events = history.snapshot();
1994 assert_eq!(events.len(), 1);
1995 assert_eq!(events[0].started_ms, 0);
1996 assert_eq!(events[0].screen_before, 0);
1997 assert_eq!(events[0].screen_at_return, 1);
1998 assert_eq!(events[0].sequence, 2);
1999 }
2000
2001 #[test]
2002 fn operation_history_uses_elapsed_time_when_the_session_clock_is_gone() {
2003 for reset in [false, true] {
2004 let mut history = OperationHistory::new();
2005 let mut pending =
2006 history.begin("close".into(), 10_000, 50, "close".into(), false, None);
2007 pending.started_at = Instant::now() - std::time::Duration::from_secs(2);
2008 if reset {
2009 history.reset_session();
2010 }
2011 history.finish(pending, None, 0, "ok", None);
2012 let event = history.snapshot().pop().unwrap();
2013 assert_eq!(event.started_ms, if reset { 0 } else { 10_000 });
2014 assert!(event.ended_ms >= event.started_ms + 2_000);
2015 }
2016 }
2017
2018 #[test]
2019 fn passing_expectations_are_reported_as_sensitive_even_without_a_terminal() {
2020 let mut history = OperationHistory::new();
2021 let pending = history.begin(
2022 "expect.output".into(),
2023 1,
2024 1,
2025 "output".into(),
2026 true,
2027 Some(OperationExpectation::Value {
2028 subject: "Command output".into(),
2029 expected: "private operand".into(),
2030 }),
2031 );
2032 history.finish(pending, Some(2), 1, "ok", None);
2033 let mut details = FailureReport::new(
2034 "later failure",
2035 None,
2036 FailureReason::InternalFailure,
2037 "failed",
2038 );
2039 details.recent_operations = history.snapshot();
2040 let sensitivity = sensitivity(&details, &[]);
2041 assert!(sensitivity.contains_assertion_operands);
2042 assert!(sensitivity.contains_user_supplied_values);
2043 }
2044
2045 #[test]
2046 fn retained_inputs_are_sensitive_without_terminal_output() {
2047 let mut history = OperationHistory::new();
2048 let pending = history.begin("write".into(), 1, 0, "wrote 6 bytes".into(), false, None);
2049 let input = InputDetails::capture(&crate::api::Operation::Write {
2050 data: "secret".into(),
2051 });
2052 history.finish(pending, Some(2), 0, "ok", input);
2053 let mut details = FailureReport::new(
2054 "later failure",
2055 None,
2056 FailureReason::InternalFailure,
2057 "failed",
2058 );
2059 details.recent_operations = history.snapshot();
2060 let sensitivity = sensitivity(&details, &[]);
2061 assert!(sensitivity.contains_input);
2062 assert!(sensitivity.contains_user_supplied_values);
2063 assert!(!sensitivity.contains_terminal_output);
2064 let markdown = markdown::render(&details, &[]);
2065 assert!(markdown.contains("secret"));
2066 assert!(markdown.contains("Input for operation 1"));
2067 }
2068
2069 #[test]
2070 fn recording_status_reflects_commit_failure() {
2071 let root =
2072 std::env::temp_dir().join(format!("tui-test-recording-commit-{}", std::process::id()));
2073 let directory = allocate_artifact_directory(&root).unwrap();
2074 fs::create_dir(directory.join("session.cast")).unwrap();
2075 let temporary_path = recording_temp_path(&directory);
2076 fs::write(&temporary_path, b"cast").unwrap();
2077
2078 let emu = AlacrittyEmu::new(1, 1, &Profile::default());
2079 let rows = emu.viewable_rows();
2080 let observation = FailureObservation {
2081 rows: rows.clone(),
2082 cols: 1,
2083 title: None,
2084 cursor: None,
2085 cursor_position: (0, 0),
2086 cursor_visible: false,
2087 cursor_shape: CursorShape::Block,
2088 render_state: RenderState::capture(&emu),
2089 screen_sequence: 1,
2090 output_revision: 1,
2091 captured_ms: 1,
2092 last_visual_change_ms: 1,
2093 history: ScreenHistory::new(1),
2094 process: ProcessDiagnostics {
2095 pid: None,
2096 state: "running".to_string(),
2097 exit_code: None,
2098 status_error: None,
2099 cancelled: false,
2100 ready: false,
2101 command_running: false,
2102 last_command_exit: None,
2103 },
2104 runtime: RuntimeDiagnostics {
2105 session_name: Some("recording-test".into()),
2106 shell: None,
2107 timeouts: None,
2108 tui_test_version: "test".to_string(),
2109 backend: "alacritty".to_string(),
2110 target_os: std::env::consts::OS.to_string(),
2111 target_arch: std::env::consts::ARCH.to_string(),
2112 },
2113 };
2114 let mut details = FailureReport::new(
2115 "locator.expect",
2116 Some(1),
2117 FailureReason::LocatorNoMatch,
2118 "missing",
2119 );
2120 details.recording = Some(RecordingDiagnostics {
2121 mode: AutomaticRecordingMode::OnFailure,
2122 status: RecordingStatus::Live,
2123 failure_offset_ms: 1,
2124 last_committed_ms: Some(1),
2125 path: None,
2126 bytes: Some(4),
2127 reason: None,
2128 ephemeral: false,
2129 });
2130 let reference = write_failure_artifact(
2131 &FailureArtifactOptions {
2132 directory: root.clone(),
2133 mode: FailureArtifactMode::Text,
2134 include_recording: true,
2135 },
2136 ArtifactInputs {
2137 details: &mut details,
2138 observation: &observation,
2139 recording: Some(PreparedRecording {
2140 temporary_path,
2141 bytes: 4,
2142 sha256: format!("sha256:{:x}", Sha256::digest(b"cast")),
2143 }),
2144 },
2145 directory,
2146 );
2147 assert_eq!(details.recording.unwrap().status, RecordingStatus::Failed);
2148 assert!(reference.recording.is_none());
2149 let _ = fs::remove_dir_all(root);
2150 }
2151}