Skip to main content

tui_test/
diagnostics.rs

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    /// Action operations may require a unique match.
146    pub effective_occurrence: MatchOccurrence,
147    pub occurrence_source: OccurrenceSource,
148    pub input_candidate_count: usize,
149    pub raw_candidate_count: usize,
150    /// Candidates remaining after applying the requested styles.
151    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    /// Whether matching searched viewport or retained scrollback.
209    pub search_scope: String,
210    /// Absolute grid row where the viewport begins.
211    pub viewport_origin_y: u32,
212    pub stages: Vec<LocatorStageDiagnostics>,
213    /// Candidates before the final occurrence is selected.
214    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    /// Groups failures by operation and locator shape.
481    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}