use std::collections::{BTreeMap, VecDeque};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::api::{
AutomaticRecordingMode, LocatorDirection, LocatorSelector, MatchOccurrence, Size, TextMatch,
TextPosition,
};
use crate::render::svg::RenderState;
use crate::terminal::cell::EmuCell;
use crate::terminal::emu::CursorShape;
mod expectation;
mod failure;
mod html;
mod input;
mod markdown;
pub(crate) mod strings;
#[cfg(test)]
mod test_fixture;
pub use expectation::{LocatorExpectation, OperationExpectation};
pub(crate) use failure::{comparison_failure, failure_reason, merge_failure_details};
pub use failure::{FailureDetails, LocatorFailure};
pub use input::{InputArguments, InputDetails, MouseTarget};
pub const FAILURE_SCHEMA_VERSION: u32 = 1;
pub const DEFAULT_SCREEN_HISTORY_LIMIT: u16 = 10;
pub const MAX_SCREEN_HISTORY_LIMIT: u16 = 50;
const FAILURE_JSON_LIMIT: usize = 2 * 1024 * 1024;
const REPORT_LIMIT: usize = 1024 * 1024;
const TIMELINE_LIMIT: usize = 8 * 1024 * 1024;
const HTML_LIMIT: usize = 128 * 1024 * 1024;
const SCREEN_TEXT_LIMIT: usize = 1024 * 1024;
const SCREEN_SVG_LIMIT: usize = 8 * 1024 * 1024;
pub(crate) const RECORDING_COPY_LIMIT: u64 = 64 * 1024 * 1024;
const ARTIFACT_TOTAL_LIMIT: u64 = 256 * 1024 * 1024;
const MAX_HISTORY_BYTES: usize = 512 * 1024;
const MAX_CHECKPOINT_BYTES: usize = 8 * 1024 * 1024;
const MAX_CONTEXT_ENTRIES: usize = 16;
const MAX_CONTEXT_KEY_BYTES: usize = 64;
const MAX_CONTEXT_VALUE_BYTES: usize = 256;
const MAX_CANDIDATES: usize = 64;
const MAX_MISMATCHES: usize = 64;
const MAX_OPERATION_HISTORY: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailureReason {
TimedOut,
SessionExited,
Cancelled,
LocatorNoMatch,
LocatorAmbiguous,
UnexpectedMatch,
MatchNotActionable,
ScalarMismatch,
SnapshotMismatch,
EmulatorFault,
InternalFailure,
Completed,
TestFailed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LocatorFailureReason {
AnchorNotFound,
AnchorAmbiguous,
RelativeRegionNoMatch,
StyleFilterRemovedAll,
LinkFilterRemovedAll,
IntersectionEmpty,
UnionEmpty,
FilterRemovedAll,
NthOutOfRange,
OutsideViewport,
MatchedNoCells,
NoMatch,
Ambiguous,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OccurrenceSource {
Explicit,
ActionDefault,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LocatorStageMode {
Text,
ContiguousStyleRuns,
ParentStyleFilter,
ContiguousLinkRuns,
ParentLinkFilter,
Intersection,
Union,
ContainmentFilter,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OperationDiagnostics {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
pub elapsed_ms: u64,
pub started_screen_sequence: u64,
pub failed_screen_sequence: u64,
}
impl OperationDiagnostics {
pub fn pending(name: impl Into<String>, timeout_ms: Option<u64>) -> Self {
Self {
name: name.into(),
timeout_ms,
elapsed_ms: 0,
started_screen_sequence: 0,
failed_screen_sequence: 0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocatorStageDiagnostics {
pub stage_index: usize,
#[serde(default)]
pub expression_path: String,
#[serde(default)]
pub evaluations: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub failure_reason: Option<LocatorFailureReason>,
pub mode: LocatorStageMode,
#[serde(skip_serializing_if = "Option::is_none")]
pub selector: Option<LocatorSelector>,
pub direction: LocatorDirection,
pub requested_occurrence: MatchOccurrence,
pub effective_occurrence: MatchOccurrence,
pub occurrence_source: OccurrenceSource,
pub input_candidate_count: usize,
pub raw_candidate_count: usize,
pub style_candidate_count: usize,
pub selected_count: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub candidates: Vec<TextMatch>,
pub candidates_truncated: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mismatches: Vec<CellMismatch>,
pub mismatches_truncated: bool,
}
impl LocatorStageDiagnostics {
pub(crate) fn truncate(&mut self) {
if self.candidates.len() > MAX_CANDIDATES {
self.candidates.truncate(MAX_CANDIDATES);
self.candidates_truncated = true;
}
if self.mismatches.len() > MAX_MISMATCHES {
self.mismatches.truncate(MAX_MISMATCHES);
self.mismatches_truncated = true;
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CellMismatch {
pub location: TextPosition,
pub grapheme: String,
pub property: String,
pub operator: String,
pub expected: String,
pub actual: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved: Option<String>,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CellStyleEvaluation {
pub matched: bool,
pub mismatches: Vec<CellMismatch>,
pub mismatches_truncated: bool,
}
impl CellStyleEvaluation {
pub(crate) fn reject(&mut self, limit: usize, capture: impl FnOnce() -> CellMismatch) -> bool {
self.matched = false;
if self.mismatches.len() == limit {
self.mismatches_truncated = true;
return false;
}
self.mismatches.push(capture());
true
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocatorDiagnostics {
pub search_scope: String,
pub viewport_origin_y: u32,
pub stages: Vec<LocatorStageDiagnostics>,
pub final_candidate_count: usize,
#[serde(default)]
pub stages_truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub evaluation_error: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub selected: Vec<TextMatch>,
#[serde(skip_serializing_if = "Option::is_none")]
pub failure_stage: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub failure_reason: Option<LocatorFailureReason>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvaluationTransition {
pub elapsed_ms: u64,
pub screen_sequence: u64,
pub outcome: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub stage_index: Option<usize>,
pub stage_counts: Vec<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OperationEvent {
pub sequence: u64,
pub name: String,
pub started_ms: u64,
pub ended_ms: u64,
pub result: String,
pub screen_before: u64,
pub screen_at_return: u64,
pub safe_summary: String,
#[serde(default)]
pub is_assertion: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expectation: Option<OperationExpectation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input: Option<InputDetails>,
}
#[derive(Debug)]
pub(crate) struct OperationHistory {
next_sequence: u64,
generation: u64,
entries: VecDeque<OperationEvent>,
}
#[derive(Debug)]
pub(crate) struct PendingOperation {
sequence: u64,
name: String,
started_at: Instant,
started_ms: u64,
screen_before: u64,
safe_summary: String,
is_assertion: bool,
expectation: Option<OperationExpectation>,
generation: u64,
}
impl PendingOperation {
pub(crate) fn sequence(&self) -> u64 {
self.sequence
}
}
impl OperationHistory {
pub(crate) fn new() -> Self {
Self {
next_sequence: 1,
generation: 0,
entries: VecDeque::new(),
}
}
pub(crate) fn begin(
&mut self,
name: String,
started_ms: u64,
screen_before: u64,
safe_summary: String,
is_assertion: bool,
expectation: Option<OperationExpectation>,
) -> PendingOperation {
let sequence = self.next_sequence;
self.next_sequence = self.next_sequence.wrapping_add(1).max(1);
PendingOperation {
sequence,
name,
started_at: Instant::now(),
started_ms,
screen_before,
safe_summary,
is_assertion,
expectation,
generation: self.generation,
}
}
pub(crate) fn reset_session(&mut self) {
self.entries.clear();
self.generation = self.generation.wrapping_add(1);
}
pub(crate) fn finish(
&mut self,
pending: PendingOperation,
ended_ms: Option<u64>,
screen_at_return: u64,
result: impl Into<String>,
input: Option<InputDetails>,
) {
let started_ms = if pending.generation == self.generation {
pending.started_ms
} else {
0
};
self.entries.push_back(OperationEvent {
sequence: pending.sequence,
name: pending.name,
started_ms,
ended_ms: ended_ms
.unwrap_or_else(|| started_ms.saturating_add(elapsed_ms(pending.started_at))),
result: result.into(),
screen_before: if pending.generation == self.generation {
pending.screen_before
} else {
0
},
screen_at_return,
safe_summary: pending.safe_summary,
is_assertion: pending.is_assertion,
expectation: pending.expectation,
input,
});
while self.entries.len() > MAX_OPERATION_HISTORY {
self.entries.pop_front();
}
}
pub(crate) fn snapshot(&self) -> Vec<OperationEvent> {
self.entries.iter().cloned().collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CursorDiagnostics {
pub column: u16,
pub row: u16,
pub visible: bool,
pub shape: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScreenSnapshotDetails {
pub sequence: u64,
pub first_seen_ms: u64,
pub last_seen_ms: u64,
pub repeat_count: u64,
pub changes: Vec<String>,
pub size: Size,
pub cursor: CursorDiagnostics,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScreenHistoryDetails {
pub limit: u16,
pub dropped_screen_count: u64,
pub dropped_row_count: u64,
#[serde(default)]
pub dropped_checkpoint_count: u64,
pub screens: Vec<ScreenSnapshotDetails>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub checkpoints: Vec<ScreenSnapshotDetails>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TerminalDiagnostics {
pub size: Size,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub cursor: CursorDiagnostics,
pub last_visual_change_ms: u64,
pub unchanged_for_ms: u64,
pub screen_history: ScreenHistoryDetails,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProcessDiagnostics {
#[serde(skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_error: Option<String>,
pub cancelled: bool,
pub ready: bool,
pub command_running: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_command_exit: Option<i32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeDiagnostics {
#[serde(skip_serializing_if = "Option::is_none")]
pub session_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shell: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeouts: Option<crate::api::EffectiveTimeouts>,
pub tui_test_version: String,
pub backend: String,
pub target_os: String,
pub target_arch: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecordingStatus {
Disabled,
Unavailable,
Live,
Copied,
Omitted,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecordingDiagnostics {
pub mode: AutomaticRecordingMode,
pub status: RecordingStatus,
pub failure_offset_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_committed_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub ephemeral: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiagnosticHint {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComparisonDiagnostics {
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expected: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actual: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FailureReport {
pub schema_version: u32,
pub signature: String,
pub operation: OperationDiagnostics,
pub reason: FailureReason,
pub summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<TraceOutcome>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locator: Option<LocatorDiagnostics>,
#[serde(skip_serializing_if = "Option::is_none")]
pub comparison: Option<ComparisonDiagnostics>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evaluation_transitions: Vec<EvaluationTransition>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub recent_operations: Vec<OperationEvent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub terminal: Option<TerminalDiagnostics>,
#[serde(skip_serializing_if = "Option::is_none")]
pub process: Option<ProcessDiagnostics>,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime: Option<RuntimeDiagnostics>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recording: Option<RecordingDiagnostics>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hints: Vec<DiagnosticHint>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub context: BTreeMap<String, String>,
pub truncated: bool,
}
impl FailureReport {
pub(crate) fn artifact_name(&self, extension: &str) -> String {
format!(
"{}.{}",
if self.outcome.is_some() {
"trace"
} else {
"failure"
},
extension
)
}
pub fn new(
operation: impl Into<String>,
timeout_ms: Option<u64>,
reason: FailureReason,
summary: impl Into<String>,
) -> Self {
Self {
schema_version: FAILURE_SCHEMA_VERSION,
signature: String::new(),
operation: OperationDiagnostics::pending(operation, timeout_ms),
reason,
summary: summary.into(),
outcome: None,
locator: None,
comparison: None,
evaluation_transitions: Vec::new(),
recent_operations: Vec::new(),
terminal: None,
process: None,
runtime: None,
recording: None,
hints: Vec::new(),
context: BTreeMap::new(),
truncated: false,
}
}
pub(crate) fn finish_signature(&mut self) {
let mut hasher = Sha256::new();
hasher.update(self.operation.name.as_bytes());
hasher.update([self.reason as u8]);
if let Some(locator) = &self.locator {
hasher.update(locator.search_scope.as_bytes());
for stage in &locator.stages {
hasher.update([stage.mode as u8, stage.direction as u8]);
hasher.update(format!("{:?}", stage.effective_occurrence).as_bytes());
}
if let Some(reason) = locator.failure_reason {
hasher.update([reason as u8]);
}
}
if let Some(runtime) = &self.runtime {
hasher.update(runtime.backend.as_bytes());
}
self.signature = format!("sha256:{:x}", hasher.finalize());
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailureArtifactMode {
#[default]
All,
Html,
Text,
None,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FailureArtifactOptions {
pub directory: PathBuf,
pub mode: FailureArtifactMode,
pub include_recording: bool,
}
impl Default for FailureArtifactOptions {
fn default() -> Self {
Self {
directory: PathBuf::new(),
mode: FailureArtifactMode::All,
include_recording: false,
}
}
}
impl FailureArtifactOptions {
pub fn validate(&self) -> Result<(), String> {
if self.mode != FailureArtifactMode::None && self.directory.as_os_str().is_empty() {
return Err("failure artifact directory must not be empty".to_string());
}
Ok(())
}
pub(crate) fn wants_text(&self) -> bool {
matches!(
self.mode,
FailureArtifactMode::All | FailureArtifactMode::Text
)
}
pub(crate) fn wants_svg(&self) -> bool {
matches!(self.mode, FailureArtifactMode::All)
}
pub(crate) fn wants_json(&self) -> bool {
matches!(
self.mode,
FailureArtifactMode::All | FailureArtifactMode::Text
)
}
pub(crate) fn wants_markdown(&self) -> bool {
matches!(
self.mode,
FailureArtifactMode::All | FailureArtifactMode::Text
)
}
pub(crate) fn wants_html(&self) -> bool {
matches!(
self.mode,
FailureArtifactMode::All | FailureArtifactMode::Html
)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ExecutionContext {
pub operation_name: Option<String>,
pub artifact: Option<FailureArtifactOptions>,
pub diagnostic_context: BTreeMap<String, String>,
pub retention: DiagnosticRetentionOptions,
pub trace: Option<TraceOptions>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TraceMode {
#[default]
Off,
On,
OnFailure,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TraceOutcome {
Passed,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TraceOptions {
pub mode: TraceMode,
pub directory: PathBuf,
}
impl Default for TraceOptions {
fn default() -> Self {
Self {
mode: TraceMode::Off,
directory: PathBuf::from(".tui-test/traces"),
}
}
}
impl TraceOptions {
pub fn validate(&self) -> Result<(), String> {
if self.directory.as_os_str().is_empty() {
return Err("trace directory must not be empty".into());
}
Ok(())
}
pub(crate) fn artifact_options(&self) -> FailureArtifactOptions {
FailureArtifactOptions {
directory: self.directory.clone(),
mode: FailureArtifactMode::All,
include_recording: true,
}
}
}
impl ExecutionContext {
pub fn with_operation(mut self, operation_name: impl Into<String>) -> Self {
self.operation_name = Some(operation_name.into());
self
}
pub fn sanitized_context(&self) -> BTreeMap<String, String> {
self.diagnostic_context
.iter()
.take(MAX_CONTEXT_ENTRIES)
.map(|(key, value)| {
let key = truncate_utf8(key, MAX_CONTEXT_KEY_BYTES);
let value = if value.len() <= MAX_CONTEXT_VALUE_BYTES {
value.clone()
} else {
format!("{}...", truncate_utf8(value, MAX_CONTEXT_VALUE_BYTES))
};
(key, value)
})
.collect()
}
}
fn truncate_utf8(value: &str, limit: usize) -> String {
if value.len() <= limit {
return value.to_string();
}
let mut end = limit;
while !value.is_char_boundary(end) {
end -= 1;
}
value[..end].to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DiagnosticRetentionOptions {
#[serde(alias = "screen-history-limit")]
pub screen_history_limit: u16,
}
impl Default for DiagnosticRetentionOptions {
fn default() -> Self {
Self {
screen_history_limit: DEFAULT_SCREEN_HISTORY_LIMIT,
}
}
}
impl DiagnosticRetentionOptions {
pub fn validate(&self) -> Result<(), String> {
if self.screen_history_limit > MAX_SCREEN_HISTORY_LIMIT {
return Err(format!(
"screen history limit must be at most {MAX_SCREEN_HISTORY_LIMIT}"
));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailureArtifactStatus {
Written,
Partial,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FailureArtifactRef {
pub status: FailureArtifactStatus,
pub directory: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub manifest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub report: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub report_html: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeline: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screen_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screen_svg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recording: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArtifactFileStatus {
Written,
Omitted,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactFile {
pub kind: String,
pub path: String,
pub status: ArtifactFileStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SensitivityDetails {
#[serde(default)]
pub contains_input: bool,
pub contains_locator_operands: bool,
pub contains_terminal_output: bool,
pub contains_terminal_title: bool,
pub contains_visual_output: bool,
pub contains_recording_output: bool,
pub contains_assertion_operands: bool,
pub contains_snapshot_evidence: bool,
pub contains_diagnostic_context: bool,
pub contains_user_supplied_values: bool,
pub permissions: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FailureArtifactManifest {
#[serde(flatten)]
pub details: FailureReport,
pub sensitivity: SensitivityDetails,
pub files: Vec<ArtifactFile>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct FailureObservation {
pub rows: Vec<Vec<EmuCell>>,
pub cols: u16,
pub title: Option<String>,
pub cursor: Option<(u16, usize)>,
pub cursor_position: (u16, u16),
pub cursor_visible: bool,
pub cursor_shape: CursorShape,
pub render_state: RenderState,
pub screen_sequence: u64,
pub output_revision: u64,
pub captured_ms: u64,
pub last_visual_change_ms: u64,
pub history: ScreenHistory,
pub process: ProcessDiagnostics,
pub runtime: RuntimeDiagnostics,
}
impl FailureObservation {
pub(crate) fn text(&self) -> String {
crate::assert::snapshot::serialize(&self.rows, self.cols, false, self.title.as_deref())
}
pub(crate) fn svg(&self) -> String {
crate::render::svg::render_svg_with_zoom(
&self.rows,
self.cols,
&self.render_state,
self.cursor,
self.title.as_deref(),
1.0,
None,
)
}
pub(crate) fn terminal(&self) -> TerminalDiagnostics {
TerminalDiagnostics {
size: Size {
cols: self.cols,
rows: self.rows.len().min(u16::MAX as usize) as u16,
},
title: self.title.clone(),
cursor: CursorDiagnostics {
column: self.cursor_position.0,
row: self.cursor_position.1,
visible: self.cursor_visible,
shape: cursor_shape_name(self.cursor_shape).to_string(),
},
last_visual_change_ms: self.last_visual_change_ms,
unchanged_for_ms: self.captured_ms.saturating_sub(self.last_visual_change_ms),
screen_history: self.history.snapshot(),
}
}
}
pub(crate) fn cursor_shape_name(shape: CursorShape) -> &'static str {
match shape {
CursorShape::Block => "block",
CursorShape::Underline => "underline",
CursorShape::Bar => "bar",
}
}
#[derive(Debug, Clone)]
pub(crate) struct ScreenHistory {
limit: u16,
dropped_screen_count: u64,
dropped_row_count: u64,
next_sequence: u64,
entries: VecDeque<std::sync::Arc<ScreenFrame>>,
checkpoints: VecDeque<std::sync::Arc<ScreenFrame>>,
checkpoint_bytes: usize,
dropped_checkpoint_count: u64,
bytes: usize,
}
#[derive(Debug, Clone)]
pub(crate) struct ScreenFrame {
details: ScreenSnapshotDetails,
rows: std::sync::Arc<Vec<Vec<EmuCell>>>,
render_state: RenderState,
}
impl ScreenHistory {
pub(crate) fn new(limit: u16) -> Self {
Self {
limit: limit.min(MAX_SCREEN_HISTORY_LIMIT),
dropped_screen_count: 0,
dropped_row_count: 0,
next_sequence: 1,
entries: VecDeque::new(),
checkpoints: VecDeque::new(),
checkpoint_bytes: 0,
dropped_checkpoint_count: 0,
bytes: 0,
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn capture(
&mut self,
rows: Vec<Vec<EmuCell>>,
cols: u16,
title: Option<String>,
cursor: (u16, u16),
cursor_visible: bool,
cursor_shape: CursorShape,
elapsed_ms: u64,
render_state: RenderState,
) -> u64 {
let text = crate::assert::snapshot::serialize(&rows, cols, false, title.as_deref());
if let Some(last) = self.entries.back_mut() {
if last.rows.as_ref() == &rows
&& last.details.title == title
&& last.details.size.cols == cols
&& last.details.size.rows == rows.len().min(u16::MAX as usize) as u16
&& last.details.cursor.column == cursor.0
&& last.details.cursor.row == cursor.1
&& last.details.cursor.visible == cursor_visible
&& last.details.cursor.shape == cursor_shape_name(cursor_shape)
&& last.render_state == render_state
{
return self.observe_current(elapsed_ms);
}
}
let mut changes = match self.entries.back() {
None => vec!["initial".to_string()],
Some(previous) => screen_changes(
&previous.rows,
&rows,
&previous.details,
&title,
cols,
cursor,
cursor_visible,
cursor_shape,
),
};
if self
.entries
.back()
.is_some_and(|previous| previous.render_state != render_state)
{
changes.push("palette".to_string());
}
let sequence = self.next_sequence;
self.next_sequence = self.next_sequence.wrapping_add(1).max(1);
let details = ScreenSnapshotDetails {
sequence,
first_seen_ms: elapsed_ms,
last_seen_ms: elapsed_ms,
repeat_count: 1,
changes,
size: Size {
cols,
rows: rows.len().min(u16::MAX as usize) as u16,
},
cursor: CursorDiagnostics {
column: cursor.0,
row: cursor.1,
visible: cursor_visible,
shape: cursor_shape_name(cursor_shape).to_string(),
},
title,
text,
};
self.bytes = self
.bytes
.saturating_add(estimate_screen_bytes(&details, &rows));
self.entries.push_back(std::sync::Arc::new(ScreenFrame {
details,
rows: std::sync::Arc::new(rows),
render_state,
}));
self.evict();
sequence
}
fn evict(&mut self) {
while self.entries.len() > usize::from(self.limit.max(1))
|| (self.bytes > MAX_HISTORY_BYTES && self.entries.len() > 1)
{
let Some(entry) = self.entries.pop_front() else {
break;
};
self.bytes = self
.bytes
.saturating_sub(estimate_screen_bytes(&entry.details, &entry.rows));
self.dropped_screen_count = self.dropped_screen_count.saturating_add(1);
self.dropped_row_count = self
.dropped_row_count
.saturating_add(entry.rows.len() as u64);
}
}
pub(crate) fn current_sequence(&self) -> u64 {
self.entries
.back()
.map_or(0, |entry| entry.details.sequence)
}
pub(crate) fn observe_current(&mut self, elapsed_ms: u64) -> u64 {
let Some(last) = self.entries.back_mut() else {
return 0;
};
let last = std::sync::Arc::make_mut(last);
last.details.last_seen_ms = elapsed_ms;
last.details.repeat_count = last.details.repeat_count.saturating_add(1);
last.details.sequence
}
pub(crate) fn snapshot(&self) -> ScreenHistoryDetails {
ScreenHistoryDetails {
limit: self.limit,
dropped_screen_count: self.dropped_screen_count,
dropped_row_count: self.dropped_row_count,
dropped_checkpoint_count: self.dropped_checkpoint_count,
screens: self
.entries
.iter()
.map(|entry| entry.details.clone())
.collect(),
checkpoints: self
.checkpoints
.iter()
.map(|entry| entry.details.clone())
.collect(),
}
}
pub(crate) fn pin_current(&mut self) {
let Some(frame) = self.entries.back() else {
return;
};
if self
.checkpoints
.back()
.is_some_and(|last| last.details.sequence == frame.details.sequence)
{
return;
}
let bytes = estimate_screen_bytes(&frame.details, &frame.rows);
if self.limit == 0 || bytes > MAX_CHECKPOINT_BYTES {
self.dropped_checkpoint_count = self.dropped_checkpoint_count.saturating_add(1);
return;
}
self.checkpoint_bytes += bytes;
self.checkpoints.push_back(frame.clone());
while self.checkpoints.len() > MAX_OPERATION_HISTORY
|| self.checkpoint_bytes > MAX_CHECKPOINT_BYTES
{
if let Some(old) = self.checkpoints.pop_front() {
self.checkpoint_bytes -= estimate_screen_bytes(&old.details, &old.rows);
self.dropped_checkpoint_count = self.dropped_checkpoint_count.saturating_add(1);
}
}
}
fn retained(&self) -> BTreeMap<u64, &std::sync::Arc<ScreenFrame>> {
self.checkpoints
.iter()
.chain(&self.entries)
.map(|frame| (frame.details.sequence, frame))
.collect()
}
#[cfg(test)]
pub(crate) fn frames(&self) -> Vec<std::sync::Arc<ScreenFrame>> {
self.retained()
.values()
.map(|frame| (*frame).clone())
.collect()
}
}
fn estimate_screen_bytes(details: &ScreenSnapshotDetails, rows: &[Vec<EmuCell>]) -> usize {
details.text.len()
+ std::mem::size_of::<RenderState>()
+ details.title.as_ref().map_or(0, String::len)
+ rows
.iter()
.flatten()
.map(|cell| {
cell.ch.len()
+ std::mem::size_of::<EmuCell>()
+ cell.hyperlink.as_ref().map_or(0, |link| {
link.uri.len() + link.id.as_ref().map_or(0, |id| id.len())
})
})
.sum::<usize>()
}
#[allow(clippy::too_many_arguments)]
fn screen_changes(
previous_rows: &[Vec<EmuCell>],
rows: &[Vec<EmuCell>],
previous: &ScreenSnapshotDetails,
title: &Option<String>,
cols: u16,
cursor: (u16, u16),
cursor_visible: bool,
cursor_shape: CursorShape,
) -> Vec<String> {
let mut changes = Vec::new();
if previous_rows != rows {
let text_changed = previous_rows.len() != rows.len()
|| previous_rows.iter().zip(rows).any(|(previous_row, row)| {
previous_row.len() != row.len()
|| previous_row
.iter()
.zip(row)
.any(|(previous_cell, cell)| previous_cell.ch != cell.ch)
});
let style_changed = previous_rows.iter().zip(rows).any(|(previous_row, row)| {
previous_row.iter().zip(row).any(|(previous_cell, cell)| {
previous_cell.fg != cell.fg
|| previous_cell.bg != cell.bg
|| previous_cell.underline != cell.underline
|| previous_cell.underline_color != cell.underline_color
|| previous_cell.attrs != cell.attrs
})
});
if text_changed {
changes.push("text".to_string());
}
if style_changed {
changes.push("style".to_string());
}
}
if previous.title != *title {
changes.push("title".to_string());
}
if previous.size.cols != cols || previous.size.rows != rows.len().min(u16::MAX as usize) as u16
{
changes.push("size".to_string());
}
if previous.cursor.column != cursor.0
|| previous.cursor.row != cursor.1
|| previous.cursor.visible != cursor_visible
|| previous.cursor.shape != cursor_shape_name(cursor_shape)
{
changes.push("cursor".to_string());
}
if changes.is_empty() {
changes.push("visual".to_string());
}
changes
}
pub(crate) struct ArtifactInputs<'a> {
pub details: &'a mut FailureReport,
pub observation: &'a FailureObservation,
pub recording: Option<PreparedRecording>,
}
#[derive(Debug, Clone)]
pub(crate) struct PreparedRecording {
pub temporary_path: PathBuf,
pub bytes: u64,
pub sha256: String,
}
pub(crate) fn allocate_artifact_directory(base: &Path) -> io::Result<PathBuf> {
allocate_directory(base, "failure")
}
pub(crate) fn allocate_trace_directory(base: &Path) -> io::Result<PathBuf> {
allocate_directory(base, "trace")
}
fn allocate_directory(base: &Path, prefix: &str) -> io::Result<PathBuf> {
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
fs::create_dir_all(base)?;
let epoch_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_millis();
for _ in 0..100 {
let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
let path = base.join(format!(
"{prefix}-{epoch_ms}-p{}-{sequence}",
std::process::id()
));
match fs::create_dir(&path) {
Ok(()) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?;
}
return Ok(path);
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"failed to allocate a unique diagnostic directory",
))
}
pub(crate) fn recording_temp_path(directory: &Path) -> PathBuf {
directory.join("session.cast.tmp")
}
pub(crate) fn write_failure_artifact(
options: &FailureArtifactOptions,
inputs: ArtifactInputs<'_>,
directory: PathBuf,
) -> FailureArtifactRef {
let mut reference = FailureArtifactRef {
status: FailureArtifactStatus::Failed,
directory: directory.to_string_lossy().into_owned(),
manifest: None,
report: None,
report_html: None,
timeline: None,
screen_text: None,
screen_svg: None,
recording: None,
errors: Vec::new(),
};
if options.mode == FailureArtifactMode::None {
return reference;
}
let markdown_name = inputs.details.artifact_name("md");
let html_name = inputs.details.artifact_name("html");
let manifest_name = inputs.details.artifact_name("json");
let mut files = Vec::new();
let mut total = 0u64;
if options.wants_text() {
let screen_text = inputs.observation.text();
write_optional_file(
&directory,
"screen_text",
"current.txt",
screen_text.as_bytes(),
SCREEN_TEXT_LIMIT as u64,
&mut total,
&mut files,
&mut reference.errors,
);
if files
.last()
.is_some_and(|file| file.status == ArtifactFileStatus::Written)
{
reference.screen_text =
Some(directory.join("current.txt").to_string_lossy().into_owned());
}
}
if options.wants_svg() {
let cell_count = inputs.observation.rows.iter().map(Vec::len).sum::<usize>();
if cell_count > 100_000 {
files.push(ArtifactFile {
kind: "screen_svg".to_string(),
path: "current.svg".to_string(),
status: ArtifactFileStatus::Omitted,
bytes: None,
sha256: None,
reason: Some("size_limit".to_string()),
});
} else {
let svg = inputs.observation.svg();
write_optional_file(
&directory,
"screen_svg",
"current.svg",
svg.as_bytes(),
SCREEN_SVG_LIMIT as u64,
&mut total,
&mut files,
&mut reference.errors,
);
}
if files
.last()
.is_some_and(|file| file.status == ArtifactFileStatus::Written)
{
reference.screen_svg =
Some(directory.join("current.svg").to_string_lossy().into_owned());
}
}
if let Some(recording) = inputs.recording {
let final_path = directory.join("session.cast");
let status = if recording.bytes > RECORDING_COPY_LIMIT
|| total.saturating_add(recording.bytes) > ARTIFACT_TOTAL_LIMIT
{
let _ = fs::remove_file(&recording.temporary_path);
if let Some(details) = inputs.details.recording.as_mut() {
details.status = RecordingStatus::Omitted;
details.path = None;
details.reason = Some("size_limit".to_string());
}
ArtifactFile {
kind: "recording".to_string(),
path: "session.cast".to_string(),
status: ArtifactFileStatus::Omitted,
bytes: Some(recording.bytes),
sha256: Some(recording.sha256),
reason: Some("size_limit".to_string()),
}
} else {
match fs::rename(&recording.temporary_path, &final_path) {
Ok(()) => {
total = total.saturating_add(recording.bytes);
reference.recording = Some(final_path.to_string_lossy().into_owned());
if let Some(details) = inputs.details.recording.as_mut() {
details.status = RecordingStatus::Copied;
details.path = Some("session.cast".to_string());
details.reason = None;
}
ArtifactFile {
kind: "recording".to_string(),
path: "session.cast".to_string(),
status: ArtifactFileStatus::Written,
bytes: Some(recording.bytes),
sha256: Some(recording.sha256),
reason: None,
}
}
Err(error) => {
let _ = fs::remove_file(&recording.temporary_path);
reference
.errors
.push(format!("failed to commit session.cast: {error}"));
if let Some(details) = inputs.details.recording.as_mut() {
details.status = RecordingStatus::Failed;
details.path = None;
details.reason = Some(error.to_string());
}
ArtifactFile {
kind: "recording".to_string(),
path: "session.cast".to_string(),
status: ArtifactFileStatus::Failed,
bytes: Some(recording.bytes),
sha256: Some(recording.sha256),
reason: Some(error.to_string()),
}
}
}
};
files.push(status);
} else if options.include_recording {
let recording = inputs.details.recording.as_ref();
let failed = recording.is_some_and(|recording| recording.status == RecordingStatus::Failed);
files.push(ArtifactFile {
kind: "recording".to_string(),
path: "session.cast".to_string(),
status: if failed {
ArtifactFileStatus::Failed
} else {
ArtifactFileStatus::Omitted
},
bytes: recording.and_then(|recording| recording.bytes),
sha256: None,
reason: Some(
recording
.and_then(|recording| recording.reason.clone())
.unwrap_or_else(|| "recording unavailable".to_string()),
),
});
}
if options.wants_markdown() || options.wants_html() {
let generated = (|| -> io::Result<()> {
let timeline = if options.wants_html() {
let timeline = html::timeline(inputs.observation)?;
inputs.details.truncated |= timeline.is_truncated();
if options.mode == FailureArtifactMode::All {
let json = serde_json::to_vec(&timeline)?;
write_optional_file(
&directory,
"timeline",
"timeline.json",
&json,
TIMELINE_LIMIT as u64,
&mut total,
&mut files,
&mut reference.errors,
);
if files
.last()
.is_some_and(|file| file.status == ArtifactFileStatus::Written)
{
reference.timeline = Some(
directory
.join("timeline.json")
.to_string_lossy()
.into_owned(),
);
}
}
Some(timeline)
} else {
None
};
if options.wants_markdown() {
let markdown = markdown::render(inputs.details, &files);
write_optional_file(
&directory,
"report",
&markdown_name,
markdown.as_bytes(),
REPORT_LIMIT as u64,
&mut total,
&mut files,
&mut reference.errors,
);
if files
.last()
.is_some_and(|file| file.status == ArtifactFileStatus::Written)
{
reference.report = Some(
directory
.join(&markdown_name)
.to_string_lossy()
.into_owned(),
);
}
}
if let Some(timeline) = timeline {
let html = html::render(
inputs.details,
&timeline,
&files,
&reference.errors,
&directory,
)?;
write_optional_file(
&directory,
"report_html",
&html_name,
html.as_bytes(),
HTML_LIMIT as u64,
&mut total,
&mut files,
&mut reference.errors,
);
if files
.last()
.is_some_and(|file| file.status == ArtifactFileStatus::Written)
{
reference.report_html =
Some(directory.join(&html_name).to_string_lossy().into_owned());
}
}
Ok(())
})();
if let Err(error) = generated {
reference.errors.push(format!(
"failed to generate failure timeline/viewer: {error}"
));
for (kind, path, requested) in [
(
"timeline",
"timeline.json",
options.mode == FailureArtifactMode::All,
),
("report", markdown_name.as_str(), options.wants_markdown()),
("report_html", html_name.as_str(), options.wants_html()),
] {
if requested && !files.iter().any(|file| file.path == path) {
files.push(ArtifactFile {
kind: kind.into(),
path: path.into(),
status: ArtifactFileStatus::Failed,
bytes: None,
sha256: None,
reason: Some(error.to_string()),
});
}
}
}
}
if !options.wants_json() {
reference.status = if reference.report_html.is_none() {
FailureArtifactStatus::Failed
} else if reference.errors.is_empty()
&& files
.iter()
.all(|file| file.status == ArtifactFileStatus::Written)
{
FailureArtifactStatus::Written
} else {
FailureArtifactStatus::Partial
};
return reference;
}
let sensitivity = sensitivity(inputs.details, &files);
let manifest = FailureArtifactManifest {
details: inputs.details.clone(),
sensitivity,
files,
errors: reference.errors.clone(),
};
let json = match serde_json::to_vec_pretty(&manifest) {
Ok(json) if json.len() <= FAILURE_JSON_LIMIT => json,
Ok(_) => {
reference
.errors
.push(format!("{manifest_name} exceeded the 2 MiB limit"));
return reference;
}
Err(error) => {
reference
.errors
.push(format!("failed to serialize {manifest_name}: {error}"));
return reference;
}
};
if total.saturating_add(json.len() as u64) > ARTIFACT_TOTAL_LIMIT {
reference.errors.push(format!(
"{manifest_name} would exceed the total artifact limit"
));
return reference;
}
let manifest_path = directory.join(&manifest_name);
match write_atomic(&manifest_path, &json) {
Ok(()) => {
reference.manifest = Some(manifest_path.to_string_lossy().into_owned());
reference.status = if reference.errors.is_empty()
&& manifest
.files
.iter()
.all(|file| file.status == ArtifactFileStatus::Written)
{
FailureArtifactStatus::Written
} else {
FailureArtifactStatus::Partial
};
}
Err(error) => reference
.errors
.push(format!("failed to commit {manifest_name}: {error}")),
}
reference
}
#[allow(clippy::too_many_arguments)]
fn write_optional_file(
directory: &Path,
kind: &str,
name: &str,
bytes: &[u8],
limit: u64,
total: &mut u64,
files: &mut Vec<ArtifactFile>,
errors: &mut Vec<String>,
) {
let length = bytes.len() as u64;
if length > limit || total.saturating_add(length) > ARTIFACT_TOTAL_LIMIT {
files.push(ArtifactFile {
kind: kind.to_string(),
path: name.to_string(),
status: ArtifactFileStatus::Omitted,
bytes: Some(length),
sha256: Some(format!("sha256:{:x}", Sha256::digest(bytes))),
reason: Some("size_limit".to_string()),
});
return;
}
let path = directory.join(name);
match write_atomic(&path, bytes) {
Ok(()) => {
*total = total.saturating_add(length);
files.push(ArtifactFile {
kind: kind.to_string(),
path: name.to_string(),
status: ArtifactFileStatus::Written,
bytes: Some(length),
sha256: Some(format!("sha256:{:x}", Sha256::digest(bytes))),
reason: None,
});
}
Err(error) => {
errors.push(format!("failed to write {name}: {error}"));
files.push(ArtifactFile {
kind: kind.to_string(),
path: name.to_string(),
status: ArtifactFileStatus::Failed,
bytes: Some(length),
sha256: Some(format!("sha256:{:x}", Sha256::digest(bytes))),
reason: Some(error.to_string()),
});
}
}
}
fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let temporary = path.with_extension(format!(
"{}.tmp",
path.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
));
fs::write(&temporary, bytes)?;
match fs::rename(&temporary, path) {
Ok(()) => Ok(()),
Err(error) => {
let _ = fs::remove_file(&temporary);
Err(error)
}
}
}
fn sensitivity(details: &FailureReport, files: &[ArtifactFile]) -> SensitivityDetails {
let has_recording = files
.iter()
.any(|file| file.kind == "recording" && file.status == ArtifactFileStatus::Written);
let has_locator = details.locator.is_some();
let has_terminal = details.terminal.is_some();
let has_context = !details.context.is_empty();
let has_expectation = details
.recent_operations
.iter()
.any(|event| event.expectation.is_some());
let has_input = details
.recent_operations
.iter()
.any(|event| event.input.is_some());
SensitivityDetails {
contains_input: has_input,
contains_locator_operands: has_locator,
contains_terminal_output: has_terminal,
contains_terminal_title: details.terminal.as_ref().is_some_and(|terminal| {
terminal.title.is_some()
|| terminal
.screen_history
.screens
.iter()
.chain(&terminal.screen_history.checkpoints)
.any(|screen| screen.title.is_some())
}),
contains_visual_output: files.iter().any(|file| {
matches!(
file.kind.as_str(),
"screen_svg" | "timeline" | "report_html"
) && file.status == ArtifactFileStatus::Written
}),
contains_recording_output: has_recording,
contains_assertion_operands: has_locator || details.comparison.is_some() || has_expectation,
contains_snapshot_evidence: details.reason == FailureReason::SnapshotMismatch,
contains_diagnostic_context: has_context,
contains_user_supplied_values: has_locator
|| has_input
|| has_expectation
|| has_terminal
|| has_recording
|| has_context
|| details.comparison.is_some(),
permissions: if cfg!(unix) {
"user_only"
} else {
"platform_default"
}
.to_string(),
}
}
fn failure_reason_code(reason: FailureReason) -> &'static str {
match reason {
FailureReason::TimedOut => "timed_out",
FailureReason::SessionExited => "session_exited",
FailureReason::Cancelled => "cancelled",
FailureReason::LocatorNoMatch => "locator_no_match",
FailureReason::LocatorAmbiguous => "locator_ambiguous",
FailureReason::UnexpectedMatch => "unexpected_match",
FailureReason::MatchNotActionable => "match_not_actionable",
FailureReason::ScalarMismatch => "scalar_mismatch",
FailureReason::SnapshotMismatch => "snapshot_mismatch",
FailureReason::EmulatorFault => "emulator_fault",
FailureReason::InternalFailure => "internal_failure",
FailureReason::Completed => "completed",
FailureReason::TestFailed => "test_failed",
}
}
pub(crate) fn elapsed_ms(started_at: Instant) -> u64 {
started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::Profile;
use crate::terminal::alacritty::AlacrittyEmu;
use crate::terminal::cell::{Attrs, EmuCell};
use crate::terminal::emu::Emulator;
#[test]
fn context_is_bounded_without_splitting_utf8() {
let mut context = ExecutionContext::default();
context
.diagnostic_context
.insert("test".to_string(), "x".repeat(300));
let values = context.sanitized_context();
assert!(values["test"].ends_with("..."));
assert!(values["test"].len() <= MAX_CONTEXT_VALUE_BYTES + 3);
}
#[test]
fn artifact_directories_do_not_overwrite() {
let root =
std::env::temp_dir().join(format!("tui-test-failure-artifact-{}", std::process::id()));
let first = allocate_artifact_directory(&root).unwrap();
let second = allocate_artifact_directory(&root).unwrap();
assert_ne!(first, second);
let _ = fs::remove_dir_all(root);
}
#[test]
fn screen_history_deduplicates_and_retains_style_changes() {
let mut history = ScreenHistory::new(3);
let emu = AlacrittyEmu::new(1, 1, &Profile::default());
let plain = vec![vec![EmuCell::blank()]];
let first = history.capture(
plain.clone(),
1,
None,
(0, 0),
true,
CursorShape::Block,
1,
RenderState::capture(&emu),
);
let repeated = history.capture(
plain.clone(),
1,
None,
(0, 0),
true,
CursorShape::Block,
2,
RenderState::capture(&emu),
);
assert_eq!(first, repeated);
assert_eq!(history.snapshot().screens[0].repeat_count, 2);
let mut styled = plain;
styled[0][0].attrs.insert(Attrs::BOLD);
let second = history.capture(
styled,
1,
None,
(0, 0),
true,
CursorShape::Block,
3,
RenderState::capture(&emu),
);
assert_ne!(first, second);
let screens = history.snapshot().screens;
assert_eq!(screens.len(), 2);
assert_eq!(screens[1].changes, vec!["style"]);
}
#[test]
fn observations_share_history_grids_but_freeze_frame_metadata() {
use std::sync::Arc;
let mut emu = AlacrittyEmu::new(80, 24, &Profile::default());
let mut history = ScreenHistory::new(32);
for index in 0..32 {
emu.process(format!("\x1b[H{index:02}").as_bytes());
history.capture(
emu.viewable_rows(),
80,
None,
emu.cursor(),
false,
CursorShape::Block,
index,
RenderState::capture(&emu),
);
history.pin_current();
}
let saved = history.clone();
let frame = saved.entries.back().unwrap();
assert!(Arc::ptr_eq(frame, history.entries.back().unwrap()));
assert!(Arc::ptr_eq(frame, history.checkpoints.back().unwrap()));
history.capture(
emu.viewable_rows(),
80,
None,
emu.cursor(),
false,
CursorShape::Block,
100,
RenderState::capture(&emu),
);
let latest = history.entries.back().unwrap();
assert!(!Arc::ptr_eq(frame, latest));
assert!(Arc::ptr_eq(&frame.rows, &latest.rows));
assert_eq!(frame.details.last_seen_ms, 31);
assert_eq!(latest.details.last_seen_ms, 100);
emu.process(b"\x1b[HLATER");
history.capture(
emu.viewable_rows(),
80,
None,
emu.cursor(),
false,
CursorShape::Block,
101,
RenderState::capture(&emu),
);
assert!(!saved
.snapshot()
.screens
.last()
.unwrap()
.text
.contains("LATER"));
assert!(history
.snapshot()
.screens
.last()
.unwrap()
.text
.contains("LATER"));
}
#[test]
fn failure_details_round_trip() {
let mut details = FailureReport::new(
"locator.expect",
Some(25),
FailureReason::LocatorNoMatch,
"missing",
);
details.finish_signature();
let encoded = serde_json::to_string(&details).unwrap();
let decoded: FailureReport = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded, details);
}
#[test]
fn checkpoints_survive_sample_eviction_and_remain_bounded() {
let emu = AlacrittyEmu::new(1, 1, &Profile::default());
let mut history = ScreenHistory::new(1);
for sequence in 1..=40 {
history.capture(
vec![vec![EmuCell {
ch: sequence.to_string().into(),
..EmuCell::blank()
}]],
1,
None,
(0, 0),
false,
CursorShape::Block,
sequence,
RenderState::capture(&emu),
);
history.pin_current();
}
let snapshot = history.snapshot();
assert_eq!(snapshot.screens.len(), 1);
assert_eq!(snapshot.checkpoints.len(), MAX_OPERATION_HISTORY);
assert_eq!(snapshot.dropped_screen_count, 39);
assert_eq!(snapshot.dropped_checkpoint_count, 8);
assert_eq!(snapshot.checkpoints[0].sequence, 9);
assert!(history.checkpoint_bytes <= MAX_CHECKPOINT_BYTES);
let mut disabled = ScreenHistory::new(0);
for sequence in 1..=3 {
disabled.capture(
vec![vec![EmuCell::blank()]],
1,
Some(sequence.to_string()),
(0, 0),
false,
CursorShape::Block,
sequence,
RenderState::capture(&emu),
);
disabled.pin_current();
}
assert_eq!(disabled.snapshot().screens.len(), 1);
assert!(disabled.checkpoints.is_empty());
assert_eq!(disabled.snapshot().dropped_checkpoint_count, 3);
let mut large = ScreenHistory::new(1);
for sequence in 1..=16 {
let mut rows = vec![vec![EmuCell::blank(); 450]; 50];
rows[0][0].ch = sequence.to_string().into();
large.capture(
rows,
450,
None,
(0, 0),
false,
CursorShape::Block,
sequence,
RenderState::capture(&emu),
);
large.pin_current();
assert!(large.checkpoint_bytes <= MAX_CHECKPOINT_BYTES);
}
assert!(
large.checkpoints.len() < 16,
"the byte budget must evict before the count budget"
);
assert!(large.snapshot().dropped_checkpoint_count > 0);
}
#[test]
fn palette_only_changes_create_distinct_pinned_frames() {
let mut emu = AlacrittyEmu::new(1, 1, &Profile::default());
let mut history = ScreenHistory::new(1);
let rows = emu.viewable_rows();
history.capture(
rows.clone(),
1,
None,
(0, 0),
false,
CursorShape::Block,
1,
RenderState::capture(&emu),
);
history.pin_current();
emu.process(b"\x1b]10;#abcdef\x07");
history.capture(
rows,
1,
None,
(0, 0),
false,
CursorShape::Block,
2,
RenderState::capture(&emu),
);
let snapshot = history.snapshot();
assert_eq!(snapshot.screens.len(), 1);
assert_eq!(snapshot.checkpoints.len(), 1);
assert!(snapshot.screens[0].changes.contains(&"palette".to_string()));
assert_ne!(
history.frames()[0].render_state,
history.frames()[1].render_state
);
}
#[test]
fn operation_history_does_not_link_frames_across_session_restarts() {
let mut history = OperationHistory::new();
let old = history.begin("old".into(), 100, 50, "old".into(), true, None);
history.finish(old, Some(120), 51, "ok", None);
let restart = history.begin("run".into(), 130, 51, "restart".into(), false, None);
history.reset_session();
history.finish(restart, Some(10), 1, "ok", None);
let events = history.snapshot();
assert_eq!(events.len(), 1);
assert_eq!(events[0].started_ms, 0);
assert_eq!(events[0].screen_before, 0);
assert_eq!(events[0].screen_at_return, 1);
assert_eq!(events[0].sequence, 2);
}
#[test]
fn operation_history_uses_elapsed_time_when_the_session_clock_is_gone() {
for reset in [false, true] {
let mut history = OperationHistory::new();
let mut pending =
history.begin("close".into(), 10_000, 50, "close".into(), false, None);
pending.started_at = Instant::now() - std::time::Duration::from_secs(2);
if reset {
history.reset_session();
}
history.finish(pending, None, 0, "ok", None);
let event = history.snapshot().pop().unwrap();
assert_eq!(event.started_ms, if reset { 0 } else { 10_000 });
assert!(event.ended_ms >= event.started_ms + 2_000);
}
}
#[test]
fn passing_expectations_are_reported_as_sensitive_even_without_a_terminal() {
let mut history = OperationHistory::new();
let pending = history.begin(
"expect.output".into(),
1,
1,
"output".into(),
true,
Some(OperationExpectation::Value {
subject: "Command output".into(),
expected: "private operand".into(),
}),
);
history.finish(pending, Some(2), 1, "ok", None);
let mut details = FailureReport::new(
"later failure",
None,
FailureReason::InternalFailure,
"failed",
);
details.recent_operations = history.snapshot();
let sensitivity = sensitivity(&details, &[]);
assert!(sensitivity.contains_assertion_operands);
assert!(sensitivity.contains_user_supplied_values);
}
#[test]
fn retained_inputs_are_sensitive_without_terminal_output() {
let mut history = OperationHistory::new();
let pending = history.begin("write".into(), 1, 0, "wrote 6 bytes".into(), false, None);
let input = InputDetails::capture(&crate::api::Operation::Write {
data: "secret".into(),
});
history.finish(pending, Some(2), 0, "ok", input);
let mut details = FailureReport::new(
"later failure",
None,
FailureReason::InternalFailure,
"failed",
);
details.recent_operations = history.snapshot();
let sensitivity = sensitivity(&details, &[]);
assert!(sensitivity.contains_input);
assert!(sensitivity.contains_user_supplied_values);
assert!(!sensitivity.contains_terminal_output);
let markdown = markdown::render(&details, &[]);
assert!(markdown.contains("secret"));
assert!(markdown.contains("Input for operation 1"));
}
#[test]
fn recording_status_reflects_commit_failure() {
let root =
std::env::temp_dir().join(format!("tui-test-recording-commit-{}", std::process::id()));
let directory = allocate_artifact_directory(&root).unwrap();
fs::create_dir(directory.join("session.cast")).unwrap();
let temporary_path = recording_temp_path(&directory);
fs::write(&temporary_path, b"cast").unwrap();
let emu = AlacrittyEmu::new(1, 1, &Profile::default());
let rows = emu.viewable_rows();
let observation = FailureObservation {
rows: rows.clone(),
cols: 1,
title: None,
cursor: None,
cursor_position: (0, 0),
cursor_visible: false,
cursor_shape: CursorShape::Block,
render_state: RenderState::capture(&emu),
screen_sequence: 1,
output_revision: 1,
captured_ms: 1,
last_visual_change_ms: 1,
history: ScreenHistory::new(1),
process: ProcessDiagnostics {
pid: None,
state: "running".to_string(),
exit_code: None,
status_error: None,
cancelled: false,
ready: false,
command_running: false,
last_command_exit: None,
},
runtime: RuntimeDiagnostics {
session_name: Some("recording-test".into()),
shell: None,
timeouts: None,
tui_test_version: "test".to_string(),
backend: "alacritty".to_string(),
target_os: std::env::consts::OS.to_string(),
target_arch: std::env::consts::ARCH.to_string(),
},
};
let mut details = FailureReport::new(
"locator.expect",
Some(1),
FailureReason::LocatorNoMatch,
"missing",
);
details.recording = Some(RecordingDiagnostics {
mode: AutomaticRecordingMode::OnFailure,
status: RecordingStatus::Live,
failure_offset_ms: 1,
last_committed_ms: Some(1),
path: None,
bytes: Some(4),
reason: None,
ephemeral: false,
});
let reference = write_failure_artifact(
&FailureArtifactOptions {
directory: root.clone(),
mode: FailureArtifactMode::Text,
include_recording: true,
},
ArtifactInputs {
details: &mut details,
observation: &observation,
recording: Some(PreparedRecording {
temporary_path,
bytes: 4,
sha256: format!("sha256:{:x}", Sha256::digest(b"cast")),
}),
},
directory,
);
assert_eq!(details.recording.unwrap().status, RecordingStatus::Failed);
assert!(reference.recording.is_none());
let _ = fs::remove_dir_all(root);
}
}