From 1b8b6ffa206baf1f73b742a283c1e2ab4d145ea1 Mon Sep 17 00:00:00 2001 From: Rain Date: Mon, 8 Jan 2024 20:53:26 -0800 Subject: [PATCH] Cleanup before landing --- Cargo.lock | 11 +- Cargo.toml | 1 + nextest-runner/Cargo.toml | 1 + nextest-runner/src/reporter.rs | 110 ++- nextest-runner/src/reporter/aggregator.rs | 162 ++++- .../src/reporter/structured/libtest.rs | 156 ++++- ...rips_human_output_custom_test_harness.snap | 5 + ...__test__strips_human_output_exec_fail.snap | 5 + ...btest__test__strips_human_output_none.snap | 5 + nextest-runner/src/runner.rs | 33 +- nextest-runner/src/test_output.rs | 646 ++---------------- nextest-runner/tests/integration/basic.rs | 9 +- nextest-runner/tests/integration/fixtures.rs | 8 +- workspace-hack/Cargo.toml | 2 +- 14 files changed, 452 insertions(+), 702 deletions(-) create mode 100644 nextest-runner/src/reporter/structured/snapshots/nextest_runner__reporter__structured__libtest__test__strips_human_output_custom_test_harness.snap create mode 100644 nextest-runner/src/reporter/structured/snapshots/nextest_runner__reporter__structured__libtest__test__strips_human_output_exec_fail.snap create mode 100644 nextest-runner/src/reporter/structured/snapshots/nextest_runner__reporter__structured__libtest__test__strips_human_output_none.snap diff --git a/Cargo.lock b/Cargo.lock index 11fe219cd44..6a29960b9d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -305,9 +305,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "542f33a8835a0884b006a0c3df3dadd99c0c3f296ed26c2fdc8028e01ad6230c" +checksum = "c48f0051a4b4c5e0b6d365cd04af53aeaa209e3cc15ec2cdb69e73cc87fbd0dc" dependencies = [ "memchr", "serde", @@ -1046,7 +1046,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" dependencies = [ "aho-corasick", - "bstr 1.8.0", + "bstr 1.9.0", "log", "regex-automata 0.4.3", "regex-syntax 0.8.2", @@ -1526,9 +1526,9 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" -version = "2.6.4" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" [[package]] name = "miette" @@ -1674,6 +1674,7 @@ dependencies = [ "aho-corasick", "async-scoped", "atomicwrites", + "bstr 1.9.0", "bytes", "camino", "camino-tempfile", diff --git a/Cargo.toml b/Cargo.toml index 32c83f40463..a7fc86dc9e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ ] [workspace.dependencies] +bstr = { version = "1.9.0", default-features = false, features = ["std"] } globset = "0.4.14" nextest-metadata = { version = "0.10.0", path = "nextest-metadata" } nextest-workspace-hack = "0.1.0" diff --git a/nextest-runner/Cargo.toml b/nextest-runner/Cargo.toml index 495098f1375..7185a6445b6 100644 --- a/nextest-runner/Cargo.toml +++ b/nextest-runner/Cargo.toml @@ -16,6 +16,7 @@ atomicwrites = "0.4.3" aho-corasick = "1.1.2" async-scoped = { version = "0.8.0", features = ["use-tokio"] } future-queue = "0.3.0" +bstr.workspace = true bytes = "1.5.0" camino = { version = "1.1.6", features = ["serde1"] } camino-tempfile = "1.1.1" diff --git a/nextest-runner/src/reporter.rs b/nextest-runner/src/reporter.rs index bdb57c7c092..ddbc32c6595 100644 --- a/nextest-runner/src/reporter.rs +++ b/nextest-runner/src/reporter.rs @@ -18,6 +18,7 @@ use crate::{ AbortStatus, ExecuteStatus, ExecutionDescription, ExecutionResult, ExecutionStatuses, RetryData, RunStats, SetupScriptExecuteStatus, }, + test_output::{TestOutput, TestSingleOutput}, }; pub use aggregator::heuristic_extract_description; use debug_ignore::DebugIgnore; @@ -750,7 +751,7 @@ impl<'a> TestReporterImpl<'a> { "only failing tests are retried" ); if self.failure_output(*failure_output).is_immediate() { - self.write_stdout_stderr(test_instance, run_status, true, writer)?; + self.write_output(test_instance, run_status, true, writer)?; } // The final output doesn't show retries, so don't store this result in @@ -816,7 +817,7 @@ impl<'a> TestReporterImpl<'a> { self.write_status_line(*test_instance, describe, writer)?; } if output_on_test_finished.show_immediate { - self.write_stdout_stderr(test_instance, last_status, true, writer)?; + self.write_output(test_instance, last_status, true, writer)?; } if let OutputStoreFinal::Yes { display_output } = output_on_test_finished.store_final @@ -1018,12 +1019,7 @@ impl<'a> TestReporterImpl<'a> { writer, )?; if *display_output { - self.write_stdout_stderr( - test_instance, - last_status, - false, - writer, - )?; + self.write_output(test_instance, last_status, false, writer)?; } } } @@ -1313,7 +1309,7 @@ impl<'a> TestReporterImpl<'a> { self.write_setup_script(script_id, command, args, writer)?; writeln!(writer, "{}", " ---".style(header_style))?; - self.write_test_output(&run_status.stdout, writer)?; + self.write_test_single_output(&run_status.stdout, writer)?; } if !run_status.stderr.is_empty() { write!(writer, "\n{}", "--- ".style(header_style))?; @@ -1321,13 +1317,13 @@ impl<'a> TestReporterImpl<'a> { self.write_setup_script(script_id, command, args, writer)?; writeln!(writer, "{}", " ---".style(header_style))?; - self.write_test_output(&run_status.stderr, writer)?; + self.write_test_single_output(&run_status.stderr, writer)?; } writeln!(writer) } - fn write_stdout_stderr( + fn write_output( &self, test_instance: &TestInstance<'a>, run_status: &ExecuteStatus, @@ -1342,58 +1338,98 @@ impl<'a> TestReporterImpl<'a> { (self.styles.fail, self.styles.fail_output) }; - { - let stdout = run_status.output.stdout(); - if !stdout.is_empty() { - write!(writer, "\n{}", "--- ".style(header_style))?; - let out_len = self.write_attempt(run_status, header_style, writer)?; - // The width is to align test instances. - write!( - writer, - "{:width$}", - "STDOUT:".style(header_style), - width = (21 - out_len) - )?; - self.write_instance(*test_instance, writer)?; - writeln!(writer, "{}", " ---".style(header_style))?; + match &run_status.output { + Some(TestOutput::Split { stdout, stderr }) => { + if !stdout.is_empty() { + write!(writer, "\n{}", "--- ".style(header_style))?; + let out_len = self.write_attempt(run_status, header_style, writer)?; + // The width is to align test instances. + write!( + writer, + "{:width$}", + "STDOUT:".style(header_style), + width = (21 - out_len) + )?; + self.write_instance(*test_instance, writer)?; + writeln!(writer, "{}", " ---".style(header_style))?; + + self.write_test_single_output(stdout, writer)?; + } + + if !stderr.is_empty() { + write!(writer, "\n{}", "--- ".style(header_style))?; + let out_len = self.write_attempt(run_status, header_style, writer)?; + // The width is to align test instances. + write!( + writer, + "{:width$}", + "STDERR:".style(header_style), + width = (21 - out_len) + )?; + self.write_instance(*test_instance, writer)?; + writeln!(writer, "{}", " ---".style(header_style))?; - self.write_test_output(&stdout, writer)?; + self.write_test_single_output(stderr, writer)?; + } } - } - { - let stderr = run_status.output.stderr(); - if !stderr.is_empty() { + Some(TestOutput::Combined { output }) => { + if !output.is_empty() { + write!(writer, "\n{}", "--- ".style(header_style))?; + let out_len = self.write_attempt(run_status, header_style, writer)?; + // The width is to align test instances. + write!( + writer, + "{:width$}", + "OUTPUT:".style(header_style), + width = (21 - out_len) + )?; + self.write_instance(*test_instance, writer)?; + writeln!(writer, "{}", " ---".style(header_style))?; + + self.write_test_single_output(output, writer)?; + } + } + + Some(TestOutput::ExecFail { description, .. }) => { write!(writer, "\n{}", "--- ".style(header_style))?; let out_len = self.write_attempt(run_status, header_style, writer)?; // The width is to align test instances. write!( writer, "{:width$}", - "STDERR:".style(header_style), + "EXECFAIL:".style(header_style), width = (21 - out_len) )?; self.write_instance(*test_instance, writer)?; writeln!(writer, "{}", " ---".style(header_style))?; - self.write_test_output(&stderr, writer)?; + writeln!(writer, "{}", description)?; + } + + None => { + // The output wasn't captured. } } writeln!(writer) } - fn write_test_output(&self, output: &[u8], writer: &mut dyn Write) -> io::Result<()> { + fn write_test_single_output( + &self, + output: &TestSingleOutput, + writer: &mut dyn Write, + ) -> io::Result<()> { if self.styles.is_colorized { const RESET_COLOR: &[u8] = b"\x1b[0m"; - // Output the text without stripping ANSI escapes, then reset the color afterwards in case - // the output is malformed. - writer.write_all(output)?; + // Output the text without stripping ANSI escapes, then reset the color afterwards in + // case the output is malformed. + writer.write_all(&output.buf)?; writer.write_all(RESET_COLOR)?; } else { // Strip ANSI escapes from the output if nextest itself isn't colorized. let mut no_color = strip_ansi_escapes::Writer::new(writer); - no_color.write_all(output)?; + no_color.write_all(&output.buf)?; } Ok(()) diff --git a/nextest-runner/src/reporter/aggregator.rs b/nextest-runner/src/reporter/aggregator.rs index 267b9bab935..edcbe82e027 100644 --- a/nextest-runner/src/reporter/aggregator.rs +++ b/nextest-runner/src/reporter/aggregator.rs @@ -12,6 +12,7 @@ use crate::{ list::TestInstance, reporter::TestEventKind, runner::{ExecuteStatus, ExecutionDescription, ExecutionResult}, + test_output::TestOutput, }; use camino::Utf8PathBuf; use chrono::{DateTime, FixedOffset, Utc}; @@ -145,20 +146,20 @@ impl<'cfg> MetadataJunit<'cfg> { for rerun in reruns { let (kind, ty) = kind_ty(rerun); - let stdout = rerun.output.stdout_lossy(); - let stderr = rerun.output.stderr_lossy(); - let stack_trace = heuristic_extract_description(rerun.result, &stdout, &stderr); - let mut test_rerun = TestRerun::new(kind); - if let Some(description) = stack_trace { - test_rerun.set_description(description); - } test_rerun .set_timestamp(to_datetime(rerun.start_time)) .set_time(rerun.time_taken) - .set_type(ty) - .set_system_out(stdout) - .set_system_err(stderr); + .set_type(ty); + + set_execute_status_props( + main_status, + // Reruns are always failures. + false, + junit_store_failure_output, + TestcaseOrRerun::Rerun(&mut test_rerun), + ); + // TODO: also publish time? it won't be standard JUnit (but maybe that's ok?) testcase_status.add_rerun(test_rerun); } @@ -174,27 +175,15 @@ impl<'cfg> MetadataJunit<'cfg> { // https://github.com/allure-framework/allure2/blob/master/plugins/junit-xml-plugin/src/main/java/io/qameta/allure/junitxml/JunitXmlPlugin.java#L192-L196 // we may have to update this format to handle that. let is_success = main_status.result.is_success(); + let store_stdout_stderr = (junit_store_success_output && is_success) + || (junit_store_failure_output && !is_success); - if !is_success || junit_store_success_output { - let stdout = main_status.output.stdout_lossy(); - let stderr = main_status.output.stderr_lossy(); - - if !is_success { - let description = - heuristic_extract_description(main_status.result, &stdout, &stderr); - if let Some(description) = description { - testcase.status.set_description(description); - } - } - - if (junit_store_success_output && is_success) - || (junit_store_failure_output && !is_success) - { - testcase - .set_system_out(&stdout) - .set_system_err_lossy(&stderr); - } - } + set_execute_status_props( + main_status, + is_success, + store_stdout_stderr, + TestcaseOrRerun::Testcase(&mut testcase), + ); testsuite.add_test_case(testcase); } @@ -255,6 +244,119 @@ impl<'cfg> MetadataJunit<'cfg> { } } +enum TestcaseOrRerun<'a> { + Testcase(&'a mut TestCase), + Rerun(&'a mut TestRerun), +} + +impl TestcaseOrRerun<'_> { + fn set_message(&mut self, message: impl Into) -> &mut Self { + match self { + TestcaseOrRerun::Testcase(testcase) => { + testcase.status.set_message(message.into()); + } + TestcaseOrRerun::Rerun(rerun) => { + rerun.set_message(message.into()); + } + } + self + } + + fn set_description(&mut self, description: impl Into) -> &mut Self { + match self { + TestcaseOrRerun::Testcase(testcase) => { + testcase.status.set_description(description.into()); + } + TestcaseOrRerun::Rerun(rerun) => { + rerun.set_description(description.into()); + } + } + self + } + + fn set_system_out(&mut self, system_out: impl Into) -> &mut Self { + match self { + TestcaseOrRerun::Testcase(testcase) => { + testcase.set_system_out(system_out.into()); + } + TestcaseOrRerun::Rerun(rerun) => { + rerun.set_system_out(system_out.into()); + } + } + self + } + + fn set_system_err(&mut self, system_err: impl Into) -> &mut Self { + match self { + TestcaseOrRerun::Testcase(testcase) => { + testcase.set_system_err(system_err.into()); + } + TestcaseOrRerun::Rerun(rerun) => { + rerun.set_system_err(system_err.into()); + } + } + self + } +} + +fn set_execute_status_props( + execute_status: &ExecuteStatus, + is_success: bool, + store_stdout_stderr: bool, + mut out: TestcaseOrRerun<'_>, +) { + match &execute_status.output { + Some(TestOutput::Split { stdout, stderr }) => { + let stdout_lossy = stdout.to_str_lossy(); + let stderr_lossy = stderr.to_str_lossy(); + if !is_success { + let description = heuristic_extract_description( + execute_status.result, + &stdout_lossy, + &stderr_lossy, + ); + if let Some(description) = description { + out.set_description(description); + } + } + + if store_stdout_stderr { + out.set_system_out(stdout_lossy) + .set_system_err(stderr_lossy); + } + } + Some(TestOutput::Combined { output }) => { + let output_lossy = output.to_str_lossy(); + if !is_success { + let description = heuristic_extract_description( + execute_status.result, + // The output is combined so we just track all of it. + &output_lossy, + &output_lossy, + ); + if let Some(description) = description { + out.set_description(description); + } + } + + if store_stdout_stderr { + out.set_system_out(output_lossy) + .set_system_err("(stdout and stderr are combined)"); + } + } + Some(TestOutput::ExecFail { + message, + description, + }) => { + out.set_message(format!("Test execution failed: {}", message)); + out.set_description(description); + } + None => { + out.set_message("Test failed, but output was not captured"); + } + } +} + fn to_datetime(system_time: SystemTime) -> DateTime { // Serialize using UTC. let datetime = DateTime::::from(system_time); diff --git a/nextest-runner/src/reporter/structured/libtest.rs b/nextest-runner/src/reporter/structured/libtest.rs index d6a7d4add57..75e66c9ee0a 100644 --- a/nextest-runner/src/reporter/structured/libtest.rs +++ b/nextest-runner/src/reporter/structured/libtest.rs @@ -25,7 +25,12 @@ use super::{ FormatVersionError, FormatVersionErrorInner, TestEvent, TestEventKind, WriteEventError, }; -use crate::{list::RustTestSuite, runner::ExecutionResult}; +use crate::{ + list::RustTestSuite, + runner::ExecutionResult, + test_output::{TestOutput, TestSingleOutput}, +}; +use bstr::ByteSlice; use nextest_metadata::MismatchReason; use std::{collections::BTreeMap, fmt::Write as _}; @@ -373,7 +378,7 @@ impl<'cfg> LibtestReporter<'cfg> { write!(out, r#","stdout":""#).map_err(fmt_err)?; strip_human_output_from_failed_test( - &last_status.output, + last_status.output.as_ref(), out, test_instance.name, )?; @@ -481,41 +486,77 @@ impl<'cfg> LibtestReporter<'cfg> { /// Unfortunately, to replicate the libtest json output, we need to do our own /// filtering of the output to strip out the data emitted by libtest in the -/// human format +/// human format. /// /// This function relies on the fact that nextest runs every individual test in -/// isolation +/// isolation. fn strip_human_output_from_failed_test( - output: &crate::test_output::TestOutput, + output: Option<&TestOutput>, out: &mut bytes::BytesMut, test_name: &str, ) -> Result<(), WriteEventError> { - let line_stripper = output - .lines() - .skip_while(|line| line.raw != b"running 1 test\n") - .skip(1) - .take_while(|line| { - if !line.chunk.stdout { - return true; + match output { + Some(TestOutput::Combined { output }) => { + strip_human_stdout_or_combined(output, out, test_name)?; + } + Some(TestOutput::Split { stdout, stderr }) => { + // This is not a case that we hit because we always set CaptureStrategy to Combined. But + // handle it in a reasonable fashion. + debug_assert!(false, "libtest output requires CaptureStrategy::Combined"); + if !stdout.is_empty() { + write!(out, "--- STDOUT ---\\n").map_err(fmt_err)?; + strip_human_stdout_or_combined(stdout, out, test_name)?; } + // If stderr is not empty, just write all of it in. + if !stderr.is_empty() { + write!(out, "\\n--- STDERR ---\\n").map_err(fmt_err)?; + write!(out, "{}", EscapedString(&stderr.to_str_lossy())).map_err(fmt_err)?; + } + } + Some(TestOutput::ExecFail { description, .. }) => { + write!(out, "--- EXEC FAIL ---\\n").map_err(fmt_err)?; + write!(out, "{}", EscapedString(description)).map_err(fmt_err)?; + } + None => { + write!(out, "(output not captured)").map_err(fmt_err)?; + } + } + Ok(()) +} - if let Some(name) = line - .raw - .strip_prefix(b"test ") - .and_then(|np| np.strip_suffix(b" ... FAILED\n")) - { - if test_name.as_bytes() == name { - return false; +fn strip_human_stdout_or_combined( + output: &TestSingleOutput, + out: &mut bytes::BytesMut, + test_name: &str, +) -> Result<(), WriteEventError> { + if output.buf.contains_str("running 1 test\n") { + // This is most likely the default test harness. + let lines = output + .lines() + .skip_while(|line| line != b"running 1 test") + .skip(1) + .take_while(|line| { + if let Some(name) = line + .strip_prefix(b"test ") + .and_then(|np| np.strip_suffix(b" ... FAILED")) + { + if test_name.as_bytes() == name { + return false; + } } - } - true - }) - .map(|line| line.lossy()); + true + }) + .map(|line| line.to_str_lossy()); - for line in line_stripper { - // This will never fail unless we are OOM - write!(out, "{}", EscapedString(&line)).map_err(fmt_err)?; + for line in lines { + // This will never fail unless we are OOM + write!(out, "{}\\n", EscapedString(&line)).map_err(fmt_err)?; + } + } else { + // This is most likely a custom test harness. Just write out the entire + // output. + write!(out, "{}", EscapedString(&output.to_str_lossy())).map_err(fmt_err)?; } Ok(()) @@ -592,6 +633,11 @@ impl<'s> std::fmt::Display for EscapedString<'s> { #[cfg(test)] mod test { + use crate::{ + reporter::structured::libtest::strip_human_output_from_failed_test, test_output::TestOutput, + }; + use bytes::BytesMut; + /// Validates that the human output portion from a failed test is stripped /// out when writing a JSON string, as it is not part of the output when /// libtest itself outputs the JSON, so we have 100% identical output to libtest @@ -626,18 +672,19 @@ note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose bac ]; let output = { - let mut acc = crate::test_output::TestOutputAccumulator::new(); - + let mut acc = BytesMut::new(); for line in TEST_OUTPUT { - acc.push_chunk(line.as_bytes(), true); + acc.extend_from_slice(line.as_bytes()); } - acc.freeze() + TestOutput::Combined { + output: acc.freeze().into(), + } }; let mut actual = bytes::BytesMut::new(); - super::strip_human_output_from_failed_test( - &output, + strip_human_output_from_failed_test( + Some(&output), &mut actual, "index::test::download_url_crates_io", ) @@ -645,4 +692,49 @@ note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose bac insta::assert_snapshot!(std::str::from_utf8(&actual).unwrap()); } + + #[test] + fn strips_human_output_custom_test_harness() { + // For a custom test harness, we don't strip the human output at all. + const TEST_OUTPUT: &[&str] = &["\n", "this is a custom test harness!!!\n", "1 test passed"]; + + let output = { + let mut acc = BytesMut::new(); + for line in TEST_OUTPUT { + acc.extend_from_slice(line.as_bytes()); + } + + TestOutput::Combined { + output: acc.freeze().into(), + } + }; + + let mut actual = bytes::BytesMut::new(); + strip_human_output_from_failed_test(Some(&output), &mut actual, "non-existent").unwrap(); + + insta::assert_snapshot!(std::str::from_utf8(&actual).unwrap()); + } + + #[test] + fn strips_human_output_exec_fail() { + let output = { + TestOutput::ExecFail { + message: "this is a message".to_owned(), + description: "this is a message\nthis is a description\n".to_owned(), + } + }; + + let mut actual = bytes::BytesMut::new(); + strip_human_output_from_failed_test(Some(&output), &mut actual, "non-existent").unwrap(); + + insta::assert_snapshot!(std::str::from_utf8(&actual).unwrap()); + } + + #[test] + fn strips_human_output_none() { + let mut actual = bytes::BytesMut::new(); + strip_human_output_from_failed_test(None, &mut actual, "non-existent").unwrap(); + + insta::assert_snapshot!(std::str::from_utf8(&actual).unwrap()); + } } diff --git a/nextest-runner/src/reporter/structured/snapshots/nextest_runner__reporter__structured__libtest__test__strips_human_output_custom_test_harness.snap b/nextest-runner/src/reporter/structured/snapshots/nextest_runner__reporter__structured__libtest__test__strips_human_output_custom_test_harness.snap new file mode 100644 index 00000000000..b81be61eff6 --- /dev/null +++ b/nextest-runner/src/reporter/structured/snapshots/nextest_runner__reporter__structured__libtest__test__strips_human_output_custom_test_harness.snap @@ -0,0 +1,5 @@ +--- +source: nextest-runner/src/reporter/structured/libtest.rs +expression: "std::str::from_utf8(&actual).unwrap()" +--- +\nthis is a custom test harness!!!\n1 test passed diff --git a/nextest-runner/src/reporter/structured/snapshots/nextest_runner__reporter__structured__libtest__test__strips_human_output_exec_fail.snap b/nextest-runner/src/reporter/structured/snapshots/nextest_runner__reporter__structured__libtest__test__strips_human_output_exec_fail.snap new file mode 100644 index 00000000000..e4290d0d232 --- /dev/null +++ b/nextest-runner/src/reporter/structured/snapshots/nextest_runner__reporter__structured__libtest__test__strips_human_output_exec_fail.snap @@ -0,0 +1,5 @@ +--- +source: nextest-runner/src/reporter/structured/libtest.rs +expression: "std::str::from_utf8(&actual).unwrap()" +--- +--- EXEC FAIL ---\nthis is a message\nthis is a description\n diff --git a/nextest-runner/src/reporter/structured/snapshots/nextest_runner__reporter__structured__libtest__test__strips_human_output_none.snap b/nextest-runner/src/reporter/structured/snapshots/nextest_runner__reporter__structured__libtest__test__strips_human_output_none.snap new file mode 100644 index 00000000000..1ae4f9eec66 --- /dev/null +++ b/nextest-runner/src/reporter/structured/snapshots/nextest_runner__reporter__structured__libtest__test__strips_human_output_none.snap @@ -0,0 +1,5 @@ +--- +source: nextest-runner/src/reporter/structured/libtest.rs +expression: "std::str::from_utf8(&actual).unwrap()" +--- +(output not captured) diff --git a/nextest-runner/src/runner.rs b/nextest-runner/src/runner.rs index 68c939d09e5..04a764c9318 100644 --- a/nextest-runner/src/runner.rs +++ b/nextest-runner/src/runner.rs @@ -21,7 +21,7 @@ use crate::{ }, signal::{JobControlEvent, ShutdownEvent, SignalEvent, SignalHandler, SignalHandlerKind}, target_runner::TargetRunner, - test_output::{CaptureStrategy, TestOutput}, + test_output::{CaptureStrategy, TestOutput, TestSingleOutput}, time::{PausableSleep, StopwatchEnd, StopwatchStart}, }; use async_scoped::TokioScope; @@ -913,15 +913,13 @@ impl<'a> TestRunnerInner<'a> { { Ok(run_status) => run_status, Err(error) => { - // Put the error chain inside stderr. - let mut acc = crate::test_output::TestOutputAccumulator::new(); - { - let mut stderr = acc.stderr_mut(); - writeln!(&mut stderr, "{}", DisplayErrorChain::new(error)).unwrap(); - } - + let message = error.to_string(); + let description = DisplayErrorChain::new(error).to_string(); InternalExecuteStatus { - output: acc.freeze(), + output: Some(TestOutput::ExecFail { + message, + description, + }), result: ExecutionResult::ExecFail, stopwatch_end: stopwatch.end(), is_slow: false, @@ -980,7 +978,7 @@ impl<'a> TestRunnerInner<'a> { let mut timeout_hit = 0; - let mut test_output = TestOutput::default(); + let mut test_output = None; let (res, leaked) = { let mut collect_output_fut = @@ -1397,7 +1395,9 @@ pub struct ExecuteStatus { /// Retry-related data. pub retry_data: RetryData, /// The stdout and stderr output for this test. - pub output: TestOutput, + /// + /// This is None if the output wasn't caught. + pub output: Option, /// The execution result for this test: pass, fail or execution error. pub result: ExecutionResult, /// The time at which the test started. @@ -1411,7 +1411,8 @@ pub struct ExecuteStatus { } struct InternalExecuteStatus { - output: TestOutput, + // This is None if output wasn't captured. + output: Option, result: ExecutionResult, stopwatch_end: StopwatchEnd, is_slow: bool, @@ -1436,9 +1437,9 @@ impl InternalExecuteStatus { #[derive(Clone, Debug)] pub struct SetupScriptExecuteStatus { /// Standard output for this setup script. - pub stdout: Bytes, + pub stdout: TestSingleOutput, /// Standard error for this setup script. - pub stderr: Bytes, + pub stderr: TestSingleOutput, /// The execution result for this setup script: pass, fail or execution error. pub result: ExecutionResult, /// The time at which the script started. @@ -1463,8 +1464,8 @@ struct InternalSetupScriptExecuteStatus { impl InternalSetupScriptExecuteStatus { fn into_external(self) -> SetupScriptExecuteStatus { SetupScriptExecuteStatus { - stdout: self.stdout, - stderr: self.stderr, + stdout: self.stdout.into(), + stderr: self.stderr.into(), result: self.result, start_time: self.stopwatch_end.start_time, time_taken: self.stopwatch_end.duration, diff --git a/nextest-runner/src/test_output.rs b/nextest-runner/src/test_output.rs index dc5e4a8f947..25c4baa3587 100644 --- a/nextest-runner/src/test_output.rs +++ b/nextest-runner/src/test_output.rs @@ -1,7 +1,9 @@ //! Utilities for capture output from tests run in a child process +use bstr::{ByteSlice, Lines}; use bytes::{Bytes, BytesMut}; -use std::{io::Write as _, ops::Range, time::Instant}; +use std::borrow::Cow; +use tokio::io::BufReader; /// The strategy used to capture test executable output #[derive(Copy, Clone, PartialEq, Default, Debug)] @@ -24,242 +26,68 @@ pub enum CaptureStrategy { None, } -/// A single chunk of captured output, this may represent 0 or more lines -#[derive(Clone, Debug)] -#[allow(dead_code)] -pub struct OutputChunk { - /// The byte range the chunk occupies in the buffer - range: Range, - /// The timestamp the chunk was read - pub timestamp: Instant, - /// True if stdout, false if stderr - pub stdout: bool, -} - -/// The complete captured output of a child process +/// A single output for a test. +/// +/// This is a wrapper around a [`Bytes`] that provides some convenience methods. #[derive(Clone, Debug)] -pub struct TestOutput { - /// The raw buffer of combined stdout and stderr +pub struct TestSingleOutput { + /// The raw output buffer pub buf: Bytes, - /// Description of each individual chunk that was streamed from the test - /// process - pub chunks: Vec, - /// The start of the beginning of the capture, so that each individual - /// chunk can get an elapsed time if needed - pub start: Instant, } -impl Default for TestOutput { - fn default() -> Self { - Self { - buf: Bytes::new(), - chunks: Vec::new(), - start: Instant::now(), - } - } -} - -impl TestOutput { - /// Gets only stdout as a lossy utf-8 string - #[inline] - pub fn stdout_lossy(&self) -> String { - self.as_string(true) - } - - /// Gets only stdout as a lossy utf-8 string - #[inline] - pub fn stderr_lossy(&self) -> String { - self.as_string(false) - } - - /// Gets the combined stdout and stderr streams as a lossy utf-8 string - #[inline] - pub fn lossy(&self) -> std::borrow::Cow<'_, str> { - String::from_utf8_lossy(&self.buf) - } - - fn as_string(&self, stdout: bool) -> String { - // Presize the buffer, assuming that we'll have well formed utf8 in - // almost all cases - let count = self - .chunks - .iter() - .filter_map(|oc| (oc.stdout == stdout).then_some(oc.range.len())) - .sum(); - - self.chunks - .iter() - .fold(String::with_capacity(count), |mut acc, oc| { - if oc.stdout != stdout { - return acc; - } - - // This is the lazy way to do this, but as stated, the normal case - // should be utf-8 strings so not a big deal if we get the occasional - // allocation - let chunk = String::from_utf8_lossy(&self.buf[oc.range.clone()]); - acc.push_str(&chunk); - acc - }) - } - - /// Gets the raw stdout buffer - #[inline] - pub fn stdout(&self) -> Bytes { - self.as_buf(true) - } - - /// Gets the raw stderr buffer - #[inline] - pub fn stderr(&self) -> Bytes { - self.as_buf(false) - } - - fn as_buf(&self, stdout: bool) -> Bytes { - let count = self - .chunks - .iter() - .filter_map(|oc| (oc.stdout == stdout).then_some(oc.range.len())) - .sum(); - - self.chunks - .iter() - .fold(bytes::BytesMut::with_capacity(count), |mut acc, oc| { - if oc.stdout != stdout { - return acc; - } - - acc.extend_from_slice(&self.buf[oc.range.clone()]); - acc - }) - .freeze() - } - - /// Retrieves an iterator over the lines in the output +impl From for TestSingleOutput { #[inline] - pub fn lines(&self) -> LinesIterator<'_> { - LinesIterator::new(self) + fn from(buf: Bytes) -> Self { + Self { buf } } } -/// Captures the stdout and/or stderr streams into a buffer, indexed on each -/// chunk of output including the timestamp and which stream it came from -pub struct TestOutputAccumulator { - buf: BytesMut, - chunks: Vec, - start: Instant, -} - -impl TestOutputAccumulator { - /// Creates a new test accumulator to capture output from a child process - #[allow(clippy::new_without_default)] - pub fn new() -> Self { - Self { - buf: BytesMut::with_capacity(4 * 1024), - chunks: Vec::new(), - start: Instant::now(), - } - } - - /// Similar to [`bytes::BytesMut::freeze`], this is called when output - /// capturing is complete to create a [`TestOutput`] of the complete - /// captured output - pub fn freeze(self) -> TestOutput { - TestOutput { - buf: self.buf.freeze(), - chunks: self.chunks, - start: self.start, - } - } - - /// Gets a writer the can be used to write to the accumulator as if a child - /// process was writing to stdout +impl TestSingleOutput { + /// Gets this output as a lossy UTF-8 string. #[inline] - pub fn stdout_mut(&mut self) -> TestOutputWriter<'_> { - TestOutputWriter { - acc: self, - stdout: true, - } + pub fn to_str_lossy(&self) -> Cow<'_, str> { + String::from_utf8_lossy(&self.buf) } - /// Gets a writer the can be used to write to the accumulator as if a child - /// process was writing to stderr + /// Iterates over lines in this output. #[inline] - pub fn stderr_mut(&mut self) -> TestOutputWriter<'_> { - TestOutputWriter { - acc: self, - stdout: false, - } + pub fn lines(&self) -> Lines<'_> { + self.buf.lines() } - /// Pushes a single chunk of output + /// Returns true if the output is empty. #[inline] - pub fn push_chunk(&mut self, chunk: &[u8], stdout: bool) { - let start = self.buf.len(); - - if self.buf.capacity() - start < chunk.len() { - self.buf.reserve(CHUNK_SIZE); - } - - self.buf.extend_from_slice(chunk); - self.chunks.push(OutputChunk { - range: start..start + chunk.len(), - timestamp: Instant::now(), - stdout, - }); + pub fn is_empty(&self) -> bool { + self.buf.is_empty() } } -/// Provides [`std::io::Write`] and [`std::fmt::Write`] implementations for a -/// single stream of a [`TestOutputAccumulator`] -pub struct TestOutputWriter<'acc> { - acc: &'acc mut TestOutputAccumulator, - stdout: bool, -} - -impl<'acc> std::io::Write for TestOutputWriter<'acc> { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - let start = self.acc.buf.len(); - self.acc.buf.extend_from_slice(buf); - self.acc.chunks.push(OutputChunk { - range: start..self.acc.buf.len(), - timestamp: Instant::now(), - stdout: self.stdout, - }); - - Ok(buf.len()) - } - - fn write_vectored(&mut self, bufs: &[std::io::IoSlice<'_>]) -> std::io::Result { - let start = self.acc.buf.len(); - - let mut len = 0; - for buf in bufs { - self.acc.buf.extend_from_slice(buf); - len += buf.len(); - } +/// The complete captured output of a child process +#[derive(Clone, Debug)] +pub enum TestOutput { + /// The output was split into stdout and stderr. + Split { + /// The captured stdout. + stdout: TestSingleOutput, - self.acc.chunks.push(OutputChunk { - range: start..self.acc.buf.len(), - timestamp: Instant::now(), - stdout: self.stdout, - }); + /// The captured stderr. + stderr: TestSingleOutput, + }, - Ok(len) - } + /// The output was combined into stdout and stderr. + Combined { + /// The captured output. + output: TestSingleOutput, + }, - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} + /// The output was an execution failure. + ExecFail { + /// A single-line message. + message: String, -impl<'acc> std::fmt::Write for TestOutputWriter<'acc> { - fn write_str(&mut self, s: &str) -> std::fmt::Result { - match self.write(s.as_bytes()) { - Ok(_) => Ok(()), - Err(_) => Err(std::fmt::Error), - } - } + /// The full description, including other errors, to print out. + description: String, + }, } /// The size of each buffered reader's buffer, and the size at which we grow @@ -274,411 +102,75 @@ use crate::errors::CollectTestOutputError as Err; /// Collects the stdout and/or stderr streams into a single buffer pub async fn collect_test_output( streams: Option, -) -> Result { +) -> Result, Err> { use tokio::io::AsyncBufReadExt as _; let Some(output) = streams else { - return Ok(TestOutput::default()); + return Ok(None); }; - let mut acc = TestOutputAccumulator::new(); - match output { crate::test_command::Output::Split { stdout, stderr } => { - let mut stdout = tokio::io::BufReader::with_capacity(CHUNK_SIZE, stdout); - let mut stderr = tokio::io::BufReader::with_capacity(CHUNK_SIZE, stderr); + let mut stdout = BufReader::with_capacity(CHUNK_SIZE, stdout); + let mut stderr = BufReader::with_capacity(CHUNK_SIZE, stderr); + + let mut stdout_acc = BytesMut::with_capacity(CHUNK_SIZE); + let mut stderr_acc = BytesMut::with_capacity(CHUNK_SIZE); let mut out_done = false; let mut err_done = false; - while !out_done || !err_done { + loop { tokio::select! { - res = stdout.fill_buf() => { + res = stdout.fill_buf(), if !out_done => { let read = { let buf = res.map_err(Err::ReadStdout)?; - acc.push_chunk(buf, true); + stdout_acc.extend_from_slice(buf); buf.len() }; stdout.consume(read); out_done = read == 0; } - res = stderr.fill_buf() => { + res = stderr.fill_buf(), if !err_done => { let read = { let buf = res.map_err(Err::ReadStderr)?; - acc.push_chunk(buf, false); + stderr_acc.extend_from_slice(buf); buf.len() }; stderr.consume(read); err_done = read == 0; } + else => break, }; } + + Ok(Some(TestOutput::Split { + stdout: stdout_acc.freeze().into(), + stderr: stderr_acc.freeze().into(), + })) } crate::test_command::Output::Combined(output) => { - let mut stdout = tokio::io::BufReader::with_capacity(CHUNK_SIZE, output); + let mut output = BufReader::with_capacity(CHUNK_SIZE, output); + let mut acc = BytesMut::with_capacity(CHUNK_SIZE); loop { let read = { - let buf = stdout.fill_buf().await.map_err(Err::ReadStdout)?; - acc.push_chunk(buf, true); + let buf = output.fill_buf().await.map_err(Err::ReadStdout)?; + acc.extend_from_slice(buf); buf.len() }; - stdout.consume(read); + output.consume(read); if read == 0 { break; } } - } - } - - Ok(acc.freeze()) -} - -struct ChunkIterator<'acc> { - chunk: &'acc OutputChunk, - haystack: &'acc [u8], - continues: bool, -} - -impl<'acc> ChunkIterator<'acc> { - fn new(buf: &'acc [u8], chunk: &'acc OutputChunk, continues: bool) -> Self { - Self { - chunk, - haystack: &buf[chunk.range.clone()], - continues, - } - } -} - -const LF: u8 = b'\n'; - -impl<'acc> Iterator for ChunkIterator<'acc> { - type Item = Line<'acc>; - - #[inline] - fn next(&mut self) -> Option { - if self.haystack.is_empty() { - return None; - } - - let (ret, remaining, has) = match memchr::memchr(LF, self.haystack) { - Some(pos) => (&self.haystack[..pos + 1], &self.haystack[pos + 1..], true), - None => (self.haystack, &[][..], false), - }; - self.haystack = remaining; - - let kind = if self.continues { - self.continues = false; - - if has { - LineKind::End - } else { - LineKind::None - } - } else if has { - LineKind::Complete - } else { - LineKind::Begin - }; - - Some(Line { - chunk: self.chunk, - raw: ret, - kind, - }) - } -} - -/// Iterator over the lines for a [`TestOutput`] -pub struct LinesIterator<'acc> { - acc: &'acc TestOutput, - cur_chunk: usize, - chunk_iter: Option>, - stdout_newline: bool, - stderr_newline: bool, -} - -impl<'acc> LinesIterator<'acc> { - fn new(acc: &'acc TestOutput) -> Self { - let mut this = Self { - acc, - cur_chunk: 0, - chunk_iter: None, - stdout_newline: true, - stderr_newline: true, - }; - - this.advance(0); - this - } - - fn advance(&mut self, chunk_ind: usize) { - let Some(chunk) = self.acc.chunks.get(chunk_ind) else { - self.chunk_iter = None; - return; - }; - - let ewnl = if chunk.stdout { - &mut self.stdout_newline - } else { - &mut self.stderr_newline - }; - self.chunk_iter = Some(ChunkIterator::new(&self.acc.buf, chunk, !*ewnl)); - self.cur_chunk = chunk_ind; - *ewnl = self.acc.buf[chunk.range.clone()].ends_with(&[LF]); - } -} - -/// The [`Line`] kind, which can help consumers processing lines -#[derive(Copy, Clone, PartialEq)] -#[cfg_attr(test, derive(Debug))] -pub enum LineKind { - /// The raw data encompasses a complete line from beginning to end - Complete, - /// The raw data begins a line, but the output chunk ends before a newline - Begin, - /// The raw data ends a line that was started in a different chunk - End, - /// No line feeds were present in the chunk - None, -} - -/// A single line of output for a test. Note that the linefeed (`\n`) is present -/// in the raw data -pub struct Line<'acc> { - /// The parent chunk which this line is a subslice of - pub chunk: &'acc OutputChunk, - /// The raw data for this line entry - pub raw: &'acc [u8], - /// The line kind - pub kind: LineKind, -} - -impl<'acc> Line<'acc> { - /// Gets the lossy string for the raw data - #[inline] - pub fn lossy(&self) -> std::borrow::Cow<'acc, str> { - String::from_utf8_lossy(self.raw) - } -} - -impl<'acc> Iterator for LinesIterator<'acc> { - type Item = Line<'acc>; - - fn next(&mut self) -> Option { - loop { - { - let Some(chunk) = &mut self.chunk_iter else { - return None; - }; - - if let Some(line) = chunk.next() { - return Some(line); - } - } - - self.advance(self.cur_chunk + 1); - } - } -} - -#[cfg(test)] -mod test { - #![allow(clippy::unused_io_amount)] - - use super::*; - use pretty_assertions::assert_str_eq; - use std::fmt::Write as _; - - macro_rules! wb { - ($o:expr, $b:expr) => { - $o.write($b).unwrap(); - }; - } - - /// Basic test for getting the combined, stream specific, and individual lines - /// from a [`TestOutput`] - #[test] - fn normal_failure_output() { - let mut acc = TestOutputAccumulator::new(); - - wb!(acc.stdout_mut(), b"\nrunning 1 test\n"); - - const TEST_OUTPUT: &[&str] = &[ - "thread 'normal_failing' panicked at tests/path.rs:44:10:", - "called `Result::unwrap()` on an `Err` value: oops", - "stack bactrace:", - " 0: rust_begin_unwind", - " at /rustc/a28077b28a02b92985b3a3faecf92813155f1ea1/library/std/src/panicking.rs:597:5", - " 1: core::panicking::panic_fmt", - " at /rustc/a28077b28a02b92985b3a3faecf92813155f1ea1/library/core/src/panicking.rs:72:14", - " 2: core::result::unwrap_failed", - " at /rustc/a28077b28a02b92985b3a3faecf92813155f1ea1/library/core/src/result.rs:1652:5", - " 3: core::result::Result::unwrap", - " at /rustc/a28077b28a02b92985b3a3faecf92813155f1ea1/library/core/src/result.rs:1077:23", - " 4: path::load", - " at ./tests/path.rs:39:9", - " 5: path::normal_failing", - " at ./tests/path.rs:224:35", - " 6: path::normal_failing::{{closure}}", - " at ./tests/path.rs:223:30", - " 7: core::ops::function::FnOnce::call_once", - " at /rustc/a28077b28a02b92985b3a3faecf92813155f1ea1/library/core/src/ops/function.rs:250:5", - " 8: core::ops::function::FnOnce::call_once", - " at /rustc/a28077b28a02b92985b3a3faecf92813155f1ea1/library/core/src/ops/function.rs:250:5", - "note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.", - ]; - - { - let err = &mut acc.stderr_mut(); - for line in &TEST_OUTPUT[..2] { - err.write_vectored(&[ - std::io::IoSlice::new(line.as_bytes()), - std::io::IoSlice::new(b"\n"), - ]) - .unwrap(); - } - - let mut backtrace_chunk = String::new(); - for line in &TEST_OUTPUT[2..TEST_OUTPUT.len() - 1] { - backtrace_chunk.push_str(line); - backtrace_chunk.push('\n'); - } - - err.write(backtrace_chunk.as_bytes()).unwrap(); - err.write_vectored(&[ - std::io::IoSlice::new(TEST_OUTPUT[TEST_OUTPUT.len() - 1].as_bytes()), - std::io::IoSlice::new(b"\n"), - ]) - .unwrap(); - } - - wb!(acc.stdout_mut(), b"test normal_failing ... FAILED\n"); - - let test_output = acc.freeze(); - - assert_str_eq!( - test_output.stdout_lossy(), - "\nrunning 1 test\ntest normal_failing ... FAILED\n" - ); - assert_str_eq!(test_output.stderr_lossy(), { - let mut to = TEST_OUTPUT.join("\n"); - to.push('\n'); - to - }); - - { - let mut combined = String::new(); - writeln!(&mut combined, "\nrunning 1 test").unwrap(); - - for line in TEST_OUTPUT { - combined.push_str(line); - combined.push('\n'); - } - - writeln!(&mut combined, "test normal_failing ... FAILED").unwrap(); - - assert_str_eq!(combined, test_output.lossy()); - } - - let mut lines = test_output.lines(); - - assert_str_eq!(lines.next().unwrap().lossy(), "\n"); - assert_str_eq!(lines.next().unwrap().lossy(), "running 1 test\n"); - - for expected in TEST_OUTPUT { - let actual = lines.next().unwrap(); - - assert_str_eq!(*expected, { - let mut lossy = actual.lossy().to_string(); - lossy.pop(); - lossy - }); - - assert_eq!(actual.kind, LineKind::Complete); - assert!(!actual.chunk.stdout); - } - - assert_str_eq!( - lines.next().unwrap().lossy(), - "test normal_failing ... FAILED\n" - ); - assert!(lines.next().is_none()); - } - - /// Tests that "split output" ie, output that is either excessively long and - /// could not be written in an individual write syscall, or even "non-typical" - /// user code that did unbuffered writes without flushing, possibly from multiple - /// threads, causing stdout and stderr output to be mixed together - #[test] - fn split_output() { - let mut acc = TestOutputAccumulator::new(); - - const CHUNKS: &[(bool, LineKind, &str)] = &[ - // Normal writes - (true, LineKind::Complete, "stdout line\n"), - (false, LineKind::Complete, "stderr line\n"), - // Writes that are split over multiple writes, but still represent a - // contiguous stream - (true, LineKind::Begin, "stdout begin..."), - (true, LineKind::None, "..."), - (true, LineKind::End, "...stdout end\n"), - (false, LineKind::Begin, "stderr begin..."), - (false, LineKind::None, "..."), - (false, LineKind::End, "...stderr end\n"), - // Writes that are split over multiple writes, but interspersed - (true, LineKind::Begin, "stdout begin..."), - (false, LineKind::Begin, "stderr begin..."), - (false, LineKind::None, "..."), - (true, LineKind::None, "..."), - (true, LineKind::None, "...\n..."), - (false, LineKind::None, "...\n..."), - (false, LineKind::End, "...stderr end\n"), - (true, LineKind::End, "...stdout end\n"), - // Normal writes - (true, LineKind::Complete, "stdout boop\nstdout end\n"), - (false, LineKind::Complete, "stderr boop\nstderr end\n"), - ]; - - for (stdout, _, chunk) in CHUNKS { - acc.push_chunk(chunk.as_bytes(), *stdout); - } - - let to = acc.freeze(); - - { - let mut combined = String::new(); - for (_, _, chunk) in CHUNKS { - combined.push_str(chunk); - } - - assert_str_eq!(combined, to.lossy()); - } - - let mut lines = to.lines(); - - let timestamp = Instant::now(); - for (stdout, kind, data) in CHUNKS { - let chunk = OutputChunk { - stdout: *stdout, - range: 0..data.len(), - timestamp, - }; - - for expected in ChunkIterator::new( - data.as_bytes(), - &chunk, - matches!(kind, LineKind::None | LineKind::End), - ) { - let line = lines.next().unwrap(); - assert_str_eq!(line.lossy(), expected.lossy()); - assert_eq!(line.kind, expected.kind, "{data}"); - assert_eq!(line.chunk.stdout, *stdout, "{data}"); - } + Ok(Some(TestOutput::Combined { + output: acc.freeze().into(), + })) } } } diff --git a/nextest-runner/tests/integration/basic.rs b/nextest-runner/tests/integration/basic.rs index fb189851314..d5ccac0ad6c 100644 --- a/nextest-runner/tests/integration/basic.rs +++ b/nextest-runner/tests/integration/basic.rs @@ -16,6 +16,7 @@ use nextest_runner::{ signal::SignalHandlerKind, target_runner::TargetRunner, test_filter::{RunIgnored, TestFilterBuilder}, + test_output::TestOutput, }; use pretty_assertions::assert_eq; use std::{io::Cursor, time::Duration}; @@ -151,8 +152,12 @@ fn test_run() -> Result<()> { if can_extract_description { // Check that stderr can be parsed heuristically. - let stdout = run_status.output.stdout_lossy(); - let stderr = run_status.output.stderr_lossy(); + let Some(TestOutput::Split { stdout, stderr }) = &run_status.output + else { + panic!("this test should always use split output") + }; + let stdout = stdout.to_str_lossy(); + let stderr = stderr.to_str_lossy(); println!("stderr: {stderr}"); let description = heuristic_extract_description(run_status.result, &stdout, &stderr); diff --git a/nextest-runner/tests/integration/fixtures.rs b/nextest-runner/tests/integration/fixtures.rs index c1b7bbdbc98..84e6c2e40c2 100644 --- a/nextest-runner/tests/integration/fixtures.rs +++ b/nextest-runner/tests/integration/fixtures.rs @@ -21,6 +21,7 @@ use nextest_runner::{ }, target_runner::TargetRunner, test_filter::TestFilterBuilder, + test_output::TestOutput, }; use once_cell::sync::Lazy; use std::{ @@ -403,14 +404,17 @@ impl fmt::Debug for InstanceStatus { InstanceStatus::Skipped(reason) => write!(f, "skipped: {reason}"), InstanceStatus::Finished(run_statuses) => { for run_status in run_statuses.iter() { + let Some(TestOutput::Split { stdout, stderr }) = &run_status.output else { + panic!("this test should always use split output") + }; write!( f, "({}/{}) {:?}\n---STDOUT---\n{}\n\n---STDERR---\n{}\n\n", run_status.retry_data.attempt, run_status.retry_data.total_attempts, run_status.result, - run_status.output.stdout_lossy(), - run_status.output.stderr_lossy(), + stdout.to_str_lossy(), + stderr.to_str_lossy(), )?; } Ok(()) diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index f5fffe05100..694f0cf0b40 100644 --- a/workspace-hack/Cargo.toml +++ b/workspace-hack/Cargo.toml @@ -25,7 +25,7 @@ futures-sink = { version = "0.3.30", default-features = false, features = ["std" getrandom = { version = "0.2.11", default-features = false, features = ["std"] } indexmap = { version = "2.1.0", features = ["serde"] } log = { version = "0.4.20", default-features = false, features = ["std"] } -memchr = { version = "2.6.4", features = ["use_std"] } +memchr = { version = "2.7.1", features = ["use_std"] } miette = { version = "5.10.0", features = ["fancy"] } num-traits = { version = "0.2.17", default-features = false, features = ["libm", "std"] } owo-colors = { version = "4.0.0", default-features = false, features = ["supports-colors"] }