#[cfg(test)]
use crate::output_spec::ArbitraryOutputSpec;
use crate::{
config::{elements::JunitFlakyFailStatus, scripts::ScriptId},
list::OwnedTestInstanceId,
output_spec::{LiveSpec, OutputSpec, SerializableOutputSpec},
reporter::{
TestOutputDisplay,
events::{
CancelReason, ExecuteStatus, ExecutionStatuses, RetryData, RunFinishedStats, RunStats,
SetupScriptExecuteStatus, StressIndex, StressProgress, TestEvent, TestEventKind,
TestSlotAssignment,
},
},
run_mode::NextestRunMode,
runner::StressCondition,
};
use chrono::{DateTime, FixedOffset};
use nextest_metadata::MismatchReason;
use quick_junit::ReportUuid;
use serde::{Deserialize, Serialize};
use std::{fmt, num::NonZero, time::Duration};
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub struct RecordOpts {
#[serde(default)]
pub run_mode: NextestRunMode,
}
impl RecordOpts {
pub fn new(run_mode: NextestRunMode) -> Self {
Self { run_mode }
}
}
#[derive_where::derive_where(Debug, PartialEq; S::ChildOutputDesc)]
#[derive(Deserialize, Serialize)]
#[serde(
rename_all = "kebab-case",
bound(
serialize = "S: SerializableOutputSpec",
deserialize = "S: SerializableOutputSpec"
)
)]
#[cfg_attr(
test,
derive(test_strategy::Arbitrary),
arbitrary(bound(S: ArbitraryOutputSpec))
)]
pub struct TestEventSummary<S: OutputSpec> {
#[cfg_attr(
test,
strategy(crate::reporter::test_helpers::arb_datetime_fixed_offset())
)]
pub timestamp: DateTime<FixedOffset>,
#[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
pub elapsed: Duration,
pub kind: TestEventKindSummary<S>,
}
impl TestEventSummary<LiveSpec> {
pub(crate) fn from_test_event(event: TestEvent<'_>) -> Option<Self> {
let kind = TestEventKindSummary::from_test_event_kind(event.kind)?;
Some(Self {
timestamp: event.timestamp,
elapsed: event.elapsed,
kind,
})
}
}
#[derive_where::derive_where(Debug, PartialEq; S::ChildOutputDesc)]
#[derive(Deserialize, Serialize)]
#[serde(
tag = "type",
rename_all = "kebab-case",
bound(
serialize = "S: SerializableOutputSpec",
deserialize = "S: SerializableOutputSpec"
)
)]
#[cfg_attr(
test,
derive(test_strategy::Arbitrary),
arbitrary(bound(S: ArbitraryOutputSpec))
)]
pub enum TestEventKindSummary<S: OutputSpec> {
Core(CoreEventKind),
Output(OutputEventKind<S>),
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
#[serde(tag = "kind", rename_all = "kebab-case")]
#[cfg_attr(test, derive(test_strategy::Arbitrary))]
pub enum CoreEventKind {
#[serde(rename_all = "kebab-case")]
RunStarted {
run_id: ReportUuid,
profile_name: String,
cli_args: Vec<String>,
stress_condition: Option<StressConditionSummary>,
},
#[serde(rename_all = "kebab-case")]
StressSubRunStarted {
progress: StressProgress,
},
#[serde(rename_all = "kebab-case")]
SetupScriptStarted {
stress_index: Option<StressIndexSummary>,
index: usize,
total: usize,
script_id: ScriptId,
program: String,
args: Vec<String>,
no_capture: bool,
},
#[serde(rename_all = "kebab-case")]
SetupScriptSlow {
stress_index: Option<StressIndexSummary>,
script_id: ScriptId,
program: String,
args: Vec<String>,
#[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
elapsed: Duration,
will_terminate: bool,
},
#[serde(rename_all = "kebab-case")]
TestStarted {
stress_index: Option<StressIndexSummary>,
test_instance: OwnedTestInstanceId,
slot_assignment: TestSlotAssignment,
current_stats: RunStats,
running: usize,
command_line: Vec<String>,
},
#[serde(rename_all = "kebab-case")]
TestSlow {
stress_index: Option<StressIndexSummary>,
test_instance: OwnedTestInstanceId,
retry_data: RetryData,
#[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
elapsed: Duration,
will_terminate: bool,
},
#[serde(rename_all = "kebab-case")]
TestRetryStarted {
stress_index: Option<StressIndexSummary>,
test_instance: OwnedTestInstanceId,
slot_assignment: TestSlotAssignment,
retry_data: RetryData,
running: usize,
command_line: Vec<String>,
},
#[serde(rename_all = "kebab-case")]
TestSkipped {
stress_index: Option<StressIndexSummary>,
test_instance: OwnedTestInstanceId,
reason: MismatchReason,
},
#[serde(rename_all = "kebab-case")]
RunBeginCancel {
setup_scripts_running: usize,
running: usize,
reason: CancelReason,
},
#[serde(rename_all = "kebab-case")]
RunPaused {
setup_scripts_running: usize,
running: usize,
},
#[serde(rename_all = "kebab-case")]
RunContinued {
setup_scripts_running: usize,
running: usize,
},
#[serde(rename_all = "kebab-case")]
StressSubRunFinished {
progress: StressProgress,
#[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
sub_elapsed: Duration,
sub_stats: RunStats,
},
#[serde(rename_all = "kebab-case")]
RunFinished {
run_id: ReportUuid,
#[cfg_attr(
test,
strategy(crate::reporter::test_helpers::arb_datetime_fixed_offset())
)]
start_time: DateTime<FixedOffset>,
#[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
elapsed: Duration,
run_stats: RunFinishedStats,
outstanding_not_seen: Option<TestsNotSeenSummary>,
},
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(test, derive(test_strategy::Arbitrary))]
pub struct TestsNotSeenSummary {
pub not_seen: Vec<OwnedTestInstanceId>,
pub total_not_seen: usize,
}
#[derive_where::derive_where(Debug, PartialEq; S::ChildOutputDesc)]
#[derive(Deserialize, Serialize)]
#[serde(
tag = "kind",
rename_all = "kebab-case",
bound(
serialize = "S: SerializableOutputSpec",
deserialize = "S: SerializableOutputSpec"
)
)]
#[cfg_attr(
test,
derive(test_strategy::Arbitrary),
arbitrary(bound(S: ArbitraryOutputSpec))
)]
pub enum OutputEventKind<S: OutputSpec> {
#[serde(rename_all = "kebab-case")]
SetupScriptFinished {
stress_index: Option<StressIndexSummary>,
index: usize,
total: usize,
script_id: ScriptId,
program: String,
args: Vec<String>,
no_capture: bool,
run_status: SetupScriptExecuteStatus<S>,
},
#[serde(rename_all = "kebab-case")]
TestAttemptFailedWillRetry {
stress_index: Option<StressIndexSummary>,
test_instance: OwnedTestInstanceId,
run_status: ExecuteStatus<S>,
#[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
delay_before_next_attempt: Duration,
failure_output: TestOutputDisplay,
running: usize,
},
#[serde(rename_all = "kebab-case")]
TestFinished {
stress_index: Option<StressIndexSummary>,
test_instance: OwnedTestInstanceId,
success_output: TestOutputDisplay,
failure_output: TestOutputDisplay,
junit_store_success_output: bool,
junit_store_failure_output: bool,
#[serde(default)]
junit_flaky_fail_status: JunitFlakyFailStatus,
run_statuses: ExecutionStatuses<S>,
current_stats: RunStats,
running: usize,
},
}
impl TestEventKindSummary<LiveSpec> {
fn from_test_event_kind(kind: TestEventKind<'_>) -> Option<Self> {
Some(match kind {
TestEventKind::RunStarted {
run_id,
test_list: _,
profile_name,
cli_args,
stress_condition,
} => Self::Core(CoreEventKind::RunStarted {
run_id,
profile_name,
cli_args,
stress_condition: stress_condition.map(StressConditionSummary::from),
}),
TestEventKind::StressSubRunStarted { progress } => {
Self::Core(CoreEventKind::StressSubRunStarted { progress })
}
TestEventKind::SetupScriptStarted {
stress_index,
index,
total,
script_id,
program,
args,
no_capture,
} => Self::Core(CoreEventKind::SetupScriptStarted {
stress_index: stress_index.map(StressIndexSummary::from),
index,
total,
script_id,
program,
args: args.to_vec(),
no_capture,
}),
TestEventKind::SetupScriptSlow {
stress_index,
script_id,
program,
args,
elapsed,
will_terminate,
} => Self::Core(CoreEventKind::SetupScriptSlow {
stress_index: stress_index.map(StressIndexSummary::from),
script_id,
program,
args: args.to_vec(),
elapsed,
will_terminate,
}),
TestEventKind::TestStarted {
stress_index,
test_instance,
slot_assignment,
current_stats,
running,
command_line,
} => Self::Core(CoreEventKind::TestStarted {
stress_index: stress_index.map(StressIndexSummary::from),
test_instance: test_instance.to_owned(),
slot_assignment,
current_stats,
running,
command_line,
}),
TestEventKind::TestSlow {
stress_index,
test_instance,
retry_data,
elapsed,
will_terminate,
} => Self::Core(CoreEventKind::TestSlow {
stress_index: stress_index.map(StressIndexSummary::from),
test_instance: test_instance.to_owned(),
retry_data,
elapsed,
will_terminate,
}),
TestEventKind::TestRetryStarted {
stress_index,
test_instance,
slot_assignment,
retry_data,
running,
command_line,
} => Self::Core(CoreEventKind::TestRetryStarted {
stress_index: stress_index.map(StressIndexSummary::from),
test_instance: test_instance.to_owned(),
slot_assignment,
retry_data,
running,
command_line,
}),
TestEventKind::TestSkipped {
stress_index,
test_instance,
reason,
} => Self::Core(CoreEventKind::TestSkipped {
stress_index: stress_index.map(StressIndexSummary::from),
test_instance: test_instance.to_owned(),
reason,
}),
TestEventKind::RunBeginCancel {
setup_scripts_running,
current_stats,
running,
} => Self::Core(CoreEventKind::RunBeginCancel {
setup_scripts_running,
running,
reason: current_stats
.cancel_reason
.expect("RunBeginCancel event has cancel reason"),
}),
TestEventKind::RunPaused {
setup_scripts_running,
running,
} => Self::Core(CoreEventKind::RunPaused {
setup_scripts_running,
running,
}),
TestEventKind::RunContinued {
setup_scripts_running,
running,
} => Self::Core(CoreEventKind::RunContinued {
setup_scripts_running,
running,
}),
TestEventKind::StressSubRunFinished {
progress,
sub_elapsed,
sub_stats,
} => Self::Core(CoreEventKind::StressSubRunFinished {
progress,
sub_elapsed,
sub_stats,
}),
TestEventKind::RunFinished {
run_id,
start_time,
elapsed,
run_stats,
outstanding_not_seen,
} => Self::Core(CoreEventKind::RunFinished {
run_id,
start_time,
elapsed,
run_stats,
outstanding_not_seen: outstanding_not_seen.map(|t| TestsNotSeenSummary {
not_seen: t.not_seen,
total_not_seen: t.total_not_seen,
}),
}),
TestEventKind::SetupScriptFinished {
stress_index,
index,
total,
script_id,
program,
args,
junit_store_success_output: _,
junit_store_failure_output: _,
no_capture,
run_status,
} => Self::Output(OutputEventKind::SetupScriptFinished {
stress_index: stress_index.map(StressIndexSummary::from),
index,
total,
script_id,
program,
args: args.to_vec(),
no_capture,
run_status,
}),
TestEventKind::TestAttemptFailedWillRetry {
stress_index,
test_instance,
run_status,
delay_before_next_attempt,
failure_output,
running,
} => Self::Output(OutputEventKind::TestAttemptFailedWillRetry {
stress_index: stress_index.map(StressIndexSummary::from),
test_instance: test_instance.to_owned(),
run_status,
delay_before_next_attempt,
failure_output,
running,
}),
TestEventKind::TestFinished {
stress_index,
test_instance,
success_output,
failure_output,
junit_store_success_output,
junit_store_failure_output,
junit_flaky_fail_status,
run_statuses,
current_stats,
running,
} => Self::Output(OutputEventKind::TestFinished {
stress_index: stress_index.map(StressIndexSummary::from),
test_instance: test_instance.to_owned(),
success_output,
failure_output,
junit_store_success_output,
junit_store_failure_output,
junit_flaky_fail_status,
run_statuses,
current_stats,
running,
}),
TestEventKind::InfoStarted { .. }
| TestEventKind::InfoResponse { .. }
| TestEventKind::InfoFinished { .. }
| TestEventKind::InputEnter { .. }
| TestEventKind::RunBeginKill { .. } => return None,
})
}
}
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(test, derive(test_strategy::Arbitrary))]
pub struct StressIndexSummary {
pub current: u32,
pub total: Option<NonZero<u32>>,
}
impl From<StressIndex> for StressIndexSummary {
fn from(index: StressIndex) -> Self {
Self {
current: index.current,
total: index.total,
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "kebab-case")]
#[cfg_attr(test, derive(test_strategy::Arbitrary))]
pub enum StressConditionSummary {
Count {
count: Option<u32>,
},
Duration {
#[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
duration: Duration,
},
}
impl From<StressCondition> for StressConditionSummary {
fn from(condition: StressCondition) -> Self {
use crate::runner::StressCount;
match condition {
StressCondition::Count(count) => Self::Count {
count: match count {
StressCount::Count { count: n } => Some(n.get()),
StressCount::Infinite => None,
},
},
StressCondition::Duration(duration) => Self::Duration { duration },
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum OutputKind {
Stdout,
Stderr,
Combined,
}
impl OutputKind {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Stdout => "stdout",
Self::Stderr => "stderr",
Self::Combined => "combined",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OutputFileName(String);
impl OutputFileName {
pub(crate) fn from_content(content: &[u8], kind: OutputKind) -> Self {
let hash = xxhash_rust::xxh3::xxh3_64(content);
Self(format!("{hash:016x}-{}", kind.as_str()))
}
pub fn as_str(&self) -> &str {
&self.0
}
fn validate(s: &str) -> bool {
if s.contains('/') || s.contains('\\') || s.contains("..") {
return false;
}
let valid_suffixes = ["-stdout", "-stderr", "-combined"];
for suffix in valid_suffixes {
if let Some(hash_part) = s.strip_suffix(suffix)
&& hash_part.len() == 16
&& hash_part
.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
{
return true;
}
}
false
}
}
impl fmt::Display for OutputFileName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for OutputFileName {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Serialize for OutputFileName {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.0.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for OutputFileName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if Self::validate(&s) {
Ok(Self(s))
} else {
Err(serde::de::Error::custom(format!(
"invalid output file name: {s}"
)))
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum ZipStoreOutput {
Empty,
#[serde(rename_all = "kebab-case")]
Full {
file_name: OutputFileName,
},
#[serde(rename_all = "kebab-case")]
Truncated {
file_name: OutputFileName,
original_size: u64,
},
}
impl ZipStoreOutput {
pub fn file_name(&self) -> Option<&OutputFileName> {
match self {
ZipStoreOutput::Empty => None,
ZipStoreOutput::Full { file_name } | ZipStoreOutput::Truncated { file_name, .. } => {
Some(file_name)
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
#[cfg_attr(test, derive(test_strategy::Arbitrary))]
pub enum ZipStoreOutputDescription {
Split {
stdout: Option<ZipStoreOutput>,
stderr: Option<ZipStoreOutput>,
},
Combined {
output: ZipStoreOutput,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output_spec::RecordingSpec;
use test_strategy::proptest;
#[proptest]
fn test_event_summary_roundtrips(value: TestEventSummary<RecordingSpec>) {
let json = serde_json::to_string(&value).expect("serialization succeeds");
let roundtrip: TestEventSummary<RecordingSpec> =
serde_json::from_str(&json).expect("deserialization succeeds");
proptest::prop_assert_eq!(value, roundtrip);
}
#[test]
fn test_output_file_name_from_content_stdout() {
let content = b"hello world";
let file_name = OutputFileName::from_content(content, OutputKind::Stdout);
let s = file_name.as_str();
assert!(s.ends_with("-stdout"), "should end with -stdout: {s}");
assert_eq!(s.len(), 16 + 1 + 6, "should be 16 hex + hyphen + 'stdout'");
let hash_part = &s[..16];
assert!(
hash_part.chars().all(|c| c.is_ascii_hexdigit()),
"hash portion should be hex: {hash_part}"
);
}
#[test]
fn test_output_file_name_from_content_stderr() {
let content = b"error message";
let file_name = OutputFileName::from_content(content, OutputKind::Stderr);
let s = file_name.as_str();
assert!(s.ends_with("-stderr"), "should end with -stderr: {s}");
assert_eq!(s.len(), 16 + 1 + 6, "should be 16 hex + hyphen + 'stderr'");
}
#[test]
fn test_output_file_name_from_content_combined() {
let content = b"combined output";
let file_name = OutputFileName::from_content(content, OutputKind::Combined);
let s = file_name.as_str();
assert!(s.ends_with("-combined"), "should end with -combined: {s}");
assert_eq!(
s.len(),
16 + 1 + 8,
"should be 16 hex + hyphen + 'combined'"
);
}
#[test]
fn test_output_file_name_deterministic() {
let content = b"deterministic content";
let name1 = OutputFileName::from_content(content, OutputKind::Stdout);
let name2 = OutputFileName::from_content(content, OutputKind::Stdout);
assert_eq!(name1.as_str(), name2.as_str());
}
#[test]
fn test_output_file_name_different_content_different_hash() {
let content1 = b"content one";
let content2 = b"content two";
let name1 = OutputFileName::from_content(content1, OutputKind::Stdout);
let name2 = OutputFileName::from_content(content2, OutputKind::Stdout);
assert_ne!(name1.as_str(), name2.as_str());
}
#[test]
fn test_output_file_name_same_content_different_kind() {
let content = b"same content";
let stdout = OutputFileName::from_content(content, OutputKind::Stdout);
let stderr = OutputFileName::from_content(content, OutputKind::Stderr);
assert_ne!(stdout.as_str(), stderr.as_str());
let stdout_hash = &stdout.as_str()[..16];
let stderr_hash = &stderr.as_str()[..16];
assert_eq!(stdout_hash, stderr_hash);
}
#[test]
fn test_output_file_name_empty_content() {
let file_name = OutputFileName::from_content(b"", OutputKind::Stdout);
let s = file_name.as_str();
assert!(s.ends_with("-stdout"), "should end with -stdout: {s}");
assert!(OutputFileName::validate(s), "should be valid: {s}");
}
#[test]
fn test_output_file_name_validate_valid_content_addressed() {
assert!(OutputFileName::validate("0123456789abcdef-stdout"));
assert!(OutputFileName::validate("fedcba9876543210-stderr"));
assert!(OutputFileName::validate("aaaaaaaaaaaaaaaa-combined"));
assert!(OutputFileName::validate("0000000000000000-stdout"));
assert!(OutputFileName::validate("ffffffffffffffff-stderr"));
}
#[test]
fn test_output_file_name_validate_invalid_patterns() {
assert!(!OutputFileName::validate("0123456789abcde-stdout"));
assert!(!OutputFileName::validate("abc-stdout"));
assert!(!OutputFileName::validate("0123456789abcdef0-stdout"));
assert!(!OutputFileName::validate("0123456789abcdef-unknown"));
assert!(!OutputFileName::validate("0123456789abcdef-out"));
assert!(!OutputFileName::validate("0123456789abcdef"));
assert!(!OutputFileName::validate("0123456789abcdeg-stdout"));
assert!(!OutputFileName::validate("0123456789ABCDEF-stdout"));
assert!(!OutputFileName::validate("../0123456789abcdef-stdout"));
assert!(!OutputFileName::validate("0123456789abcdef-stdout/"));
assert!(!OutputFileName::validate("foo/0123456789abcdef-stdout"));
assert!(!OutputFileName::validate("..\\0123456789abcdef-stdout"));
}
#[test]
fn test_output_file_name_validate_rejects_old_format() {
assert!(!OutputFileName::validate("test-abc123-1-stdout"));
assert!(!OutputFileName::validate("test-abc123-s5-1-stderr"));
assert!(!OutputFileName::validate("script-def456-stdout"));
assert!(!OutputFileName::validate("script-def456-s3-stderr"));
}
#[test]
fn test_output_file_name_serde_round_trip() {
let content = b"test content for serde";
let original = OutputFileName::from_content(content, OutputKind::Stdout);
let json = serde_json::to_string(&original).expect("serialization failed");
let deserialized: OutputFileName =
serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(original.as_str(), deserialized.as_str());
}
#[test]
fn test_output_file_name_deserialize_invalid() {
let json = r#""invalid-file-name""#;
let result: Result<OutputFileName, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"should fail to deserialize invalid pattern"
);
let json = r#""test-abc123-1-stdout""#; let result: Result<OutputFileName, _> = serde_json::from_str(json);
assert!(result.is_err(), "should reject old format");
}
#[test]
fn test_zip_store_output_file_name() {
let content = b"some output";
let file_name = OutputFileName::from_content(content, OutputKind::Stdout);
let empty = ZipStoreOutput::Empty;
assert!(empty.file_name().is_none());
let full = ZipStoreOutput::Full {
file_name: file_name.clone(),
};
assert_eq!(
full.file_name().map(|f| f.as_str()),
Some(file_name.as_str())
);
let truncated = ZipStoreOutput::Truncated {
file_name: file_name.clone(),
original_size: 1000,
};
assert_eq!(
truncated.file_name().map(|f| f.as_str()),
Some(file_name.as_str())
);
}
}