Skip to content

Commit 0aaa2d6

Browse files
committed
[cargo-nextest] add debug extract
The heuristic extraction process can be mysterious. Add this debugging command to make it less so.
1 parent e738273 commit 0aaa2d6

File tree

6 files changed

+230
-18
lines changed

6 files changed

+230
-18
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cargo-nextest/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ nextest-metadata = { version = "=0.12.0", path = "../nextest-metadata" }
3232
once_cell = "1.19.0"
3333
owo-colors.workspace = true
3434
pathdiff = { version = "0.2.1", features = ["camino"] }
35+
quick-junit.workspace = true
3536
semver = "1.0.23"
3637
shell-words = "1.1.0"
3738
supports-color = "2.1.0"

cargo-nextest/src/dispatch.rs

Lines changed: 186 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,15 @@ use nextest_runner::{
3030
partition::PartitionerBuilder,
3131
platform::{BuildPlatforms, HostPlatform, PlatformLibdir, TargetPlatform},
3232
redact::Redactor,
33-
reporter::{structured, FinalStatusLevel, StatusLevel, TestOutputDisplay, TestReporterBuilder},
33+
reporter::{
34+
heuristic_extract_description, highlight_end, structured, DescriptionKind,
35+
FinalStatusLevel, StatusLevel, TestOutputDisplay, TestReporterBuilder,
36+
},
3437
reuse_build::{archive_to_file, ArchiveReporter, PathMapper, ReuseBuildInfo},
35-
runner::{configure_handle_inheritance, FinalRunStats, RunStatsFailureKind, TestRunnerBuilder},
38+
runner::{
39+
configure_handle_inheritance, ExecutionResult, FinalRunStats, RunStatsFailureKind,
40+
TestRunnerBuilder,
41+
},
3642
show_config::{ShowNextestVersion, ShowTestGroupSettings, ShowTestGroups, ShowTestGroupsMode},
3743
signal::SignalHandlerKind,
3844
target_runner::{PlatformRunner, TargetRunner},
@@ -42,10 +48,12 @@ use nextest_runner::{
4248
};
4349
use once_cell::sync::OnceCell;
4450
use owo_colors::{OwoColorize, Stream, Style};
51+
use quick_junit::XmlString;
4552
use semver::Version;
4653
use std::{
4754
collections::BTreeSet,
4855
env::VarError,
56+
fmt,
4957
io::{Cursor, Write},
5058
sync::Arc,
5159
};
@@ -170,6 +178,7 @@ impl AppOpts {
170178
output_writer,
171179
),
172180
Command::Self_ { command } => command.exec(self.common.output),
181+
Command::Debug { command } => command.exec(self.common.output),
173182
}
174183
}
175184
}
@@ -378,6 +387,15 @@ enum Command {
378387
#[clap(subcommand)]
379388
command: SelfCommand,
380389
},
390+
/// Debug commands
391+
///
392+
/// The commands in this section are for nextest's own developers and those integrating with it
393+
/// to debug issues. They are not part of the public API and may change at any time.
394+
#[clap(hide = true)]
395+
Debug {
396+
#[clap(subcommand)]
397+
command: DebugCommand,
398+
},
381399
}
382400

383401
#[derive(Debug, Args)]
@@ -1971,6 +1989,172 @@ impl SelfCommand {
19711989
}
19721990
}
19731991

1992+
#[derive(Debug, Subcommand)]
1993+
enum DebugCommand {
1994+
/// Show the data that nextest would extract from standard output or standard error.
1995+
///
1996+
/// Text extraction is a heuristic process driven by a bunch of regexes and other similar logic.
1997+
/// This command shows what nextest would extract from a given input.
1998+
Extract {
1999+
/// The path to the standard output produced by the test process.
2000+
#[arg(long, required_unless_present_any = ["stderr", "combined"])]
2001+
stdout: Option<Utf8PathBuf>,
2002+
2003+
/// The path to the standard error produced by the test process.
2004+
#[arg(long, required_unless_present_any = ["stdout", "combined"])]
2005+
stderr: Option<Utf8PathBuf>,
2006+
2007+
/// The combined output produced by the test process.
2008+
#[arg(long, conflicts_with_all = ["stdout", "stderr"])]
2009+
combined: Option<Utf8PathBuf>,
2010+
2011+
/// The kind of output to produce.
2012+
#[arg(value_enum)]
2013+
output_format: ExtractOutputFormat,
2014+
},
2015+
}
2016+
2017+
impl DebugCommand {
2018+
fn exec(self, output: OutputOpts) -> Result<i32> {
2019+
let _ = output.init();
2020+
2021+
match self {
2022+
DebugCommand::Extract {
2023+
stdout,
2024+
stderr,
2025+
combined,
2026+
output_format,
2027+
} => {
2028+
// Either stdout + stderr or combined must be present.
2029+
if let Some(combined) = combined {
2030+
let combined = std::fs::read(&combined).map_err(|err| {
2031+
ExpectedError::DebugExtractReadError {
2032+
kind: "combined",
2033+
path: combined,
2034+
err,
2035+
}
2036+
})?;
2037+
2038+
let description_kind = extract_description(&combined, &combined);
2039+
display_description_kind(description_kind, output_format)?;
2040+
} else {
2041+
let stdout = stdout
2042+
.map(|path| {
2043+
std::fs::read(&path).map_err(|err| {
2044+
ExpectedError::DebugExtractReadError {
2045+
kind: "stdout",
2046+
path,
2047+
err,
2048+
}
2049+
})
2050+
})
2051+
.transpose()?
2052+
.unwrap_or_default();
2053+
let stderr = stderr
2054+
.map(|path| {
2055+
std::fs::read(&path).map_err(|err| {
2056+
ExpectedError::DebugExtractReadError {
2057+
kind: "stderr",
2058+
path,
2059+
err,
2060+
}
2061+
})
2062+
})
2063+
.transpose()?
2064+
.unwrap_or_default();
2065+
2066+
let description_kind = extract_description(&stdout, &stderr);
2067+
display_description_kind(description_kind, output_format)?;
2068+
}
2069+
}
2070+
}
2071+
2072+
Ok(0)
2073+
}
2074+
}
2075+
2076+
fn extract_description<'a>(stdout: &'a [u8], stderr: &'a [u8]) -> Option<DescriptionKind<'a>> {
2077+
// The execution result is a generic one.
2078+
heuristic_extract_description(
2079+
ExecutionResult::Fail {
2080+
abort_status: None,
2081+
leaked: false,
2082+
},
2083+
stdout,
2084+
stderr,
2085+
)
2086+
}
2087+
2088+
fn display_description_kind(
2089+
kind: Option<DescriptionKind<'_>>,
2090+
output_format: ExtractOutputFormat,
2091+
) -> Result<()> {
2092+
match output_format {
2093+
ExtractOutputFormat::Raw => {
2094+
if let Some(kind) = kind {
2095+
if let Some(out) = kind.combined_subslice() {
2096+
return std::io::stdout().write_all(out.slice).map_err(|err| {
2097+
ExpectedError::DebugExtractWriteError {
2098+
format: output_format,
2099+
err,
2100+
}
2101+
});
2102+
}
2103+
}
2104+
}
2105+
ExtractOutputFormat::JunitDescription => {
2106+
if let Some(kind) = kind {
2107+
println!(
2108+
"{}",
2109+
XmlString::new(kind.display_human().to_string()).as_str()
2110+
);
2111+
}
2112+
}
2113+
ExtractOutputFormat::Highlight => {
2114+
if let Some(kind) = kind {
2115+
if let Some(out) = kind.combined_subslice() {
2116+
let end = highlight_end(out.slice);
2117+
return std::io::stdout()
2118+
.write_all(&out.slice[..end])
2119+
.map_err(|err| ExpectedError::DebugExtractWriteError {
2120+
format: output_format,
2121+
err,
2122+
});
2123+
}
2124+
}
2125+
}
2126+
}
2127+
2128+
eprintln!("(no description found)");
2129+
Ok(())
2130+
}
2131+
2132+
/// Output format for `nextest debug extract`.
2133+
#[derive(Clone, Copy, Debug, ValueEnum)]
2134+
pub enum ExtractOutputFormat {
2135+
/// Show the raw text extracted.
2136+
Raw,
2137+
2138+
/// Show what would be put in the description field of JUnit reports.
2139+
///
2140+
/// This is similar to `Raw`, but is valid Unicode, and strips out ANSI escape codes and other
2141+
/// invalid XML characters.
2142+
JunitDescription,
2143+
2144+
/// Show what would be highlighted in nextest's output.
2145+
Highlight,
2146+
}
2147+
2148+
impl fmt::Display for ExtractOutputFormat {
2149+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2150+
match self {
2151+
Self::Raw => write!(f, "raw"),
2152+
Self::JunitDescription => write!(f, "junit-description"),
2153+
Self::Highlight => write!(f, "highlight"),
2154+
}
2155+
}
2156+
}
2157+
19742158
fn acquire_graph_data(
19752159
manifest_path: Option<&Utf8Path>,
19762160
target_dir: Option<&Utf8Path>,

cargo-nextest/src/errors.rs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) The nextest Contributors
22
// SPDX-License-Identifier: MIT OR Apache-2.0
33

4+
use crate::ExtractOutputFormat;
45
use camino::Utf8PathBuf;
56
use itertools::Itertools;
67
use nextest_filtering::errors::FiltersetParseErrors;
@@ -249,6 +250,19 @@ pub enum ExpectedError {
249250
#[from]
250251
err: FormatVersionError,
251252
},
253+
#[error("extract read error")]
254+
DebugExtractReadError {
255+
kind: &'static str,
256+
path: Utf8PathBuf,
257+
#[source]
258+
err: std::io::Error,
259+
},
260+
#[error("extract write error")]
261+
DebugExtractWriteError {
262+
format: ExtractOutputFormat,
263+
#[source]
264+
err: std::io::Error,
265+
},
252266
}
253267

254268
impl ExpectedError {
@@ -386,7 +400,8 @@ impl ExpectedError {
386400
| Self::DialoguerError { .. }
387401
| Self::SignalHandlerSetupError { .. }
388402
| Self::ShowTestGroupsError { .. }
389-
| Self::InvalidMessageFormatVersion { .. } => NextestExitCode::SETUP_ERROR,
403+
| Self::InvalidMessageFormatVersion { .. }
404+
| Self::DebugExtractReadError { .. } => NextestExitCode::SETUP_ERROR,
390405
Self::ConfigParseError { err } => {
391406
// Experimental features not being enabled are their own error.
392407
match err.kind() {
@@ -412,9 +427,9 @@ impl ExpectedError {
412427
Self::TestRunFailed => NextestExitCode::TEST_RUN_FAILED,
413428
Self::NoTestsRun { .. } => NextestExitCode::NO_TESTS_RUN,
414429
Self::ArchiveCreateError { .. } => NextestExitCode::ARCHIVE_CREATION_FAILED,
415-
Self::WriteTestListError { .. } | Self::WriteEventError { .. } => {
416-
NextestExitCode::WRITE_OUTPUT_ERROR
417-
}
430+
Self::WriteTestListError { .. }
431+
| Self::WriteEventError { .. }
432+
| Self::DebugExtractWriteError { .. } => NextestExitCode::WRITE_OUTPUT_ERROR,
418433
#[cfg(feature = "self-update")]
419434
Self::UpdateError { .. } => NextestExitCode::UPDATE_ERROR,
420435
Self::ExperimentalFeatureNotEnabled { .. } => {
@@ -833,6 +848,17 @@ impl ExpectedError {
833848
log::error!("error parsing message format version");
834849
Some(err as &dyn Error)
835850
}
851+
Self::DebugExtractReadError { kind, path, err } => {
852+
log::error!(
853+
"error reading {kind} file `{}`",
854+
path.if_supports_color(Stream::Stderr, |x| x.bold()),
855+
);
856+
Some(err as &dyn Error)
857+
}
858+
Self::DebugExtractWriteError { format, err } => {
859+
log::error!("error writing {format} output");
860+
Some(err as &dyn Error)
861+
}
836862
};
837863

838864
while let Some(err) = next_error {

nextest-runner/src/reporter/helpers.rs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ pub enum DescriptionKind<'a> {
3434
leaked: bool,
3535
},
3636

37-
/// A stack trace was found in the output.
37+
/// A panic message was found in the output.
3838
///
3939
/// The output is borrowed from standard error.
40-
StackTrace {
40+
PanicMessage {
4141
/// The subslice of standard error that contains the stack trace.
4242
stderr_subslice: ByteSubslice<'a>,
4343
},
@@ -64,7 +64,7 @@ impl<'a> DescriptionKind<'a> {
6464
pub fn stderr_subslice(&self) -> Option<ByteSubslice<'a>> {
6565
match self {
6666
DescriptionKind::Abort { .. } => None,
67-
DescriptionKind::StackTrace { stderr_subslice }
67+
DescriptionKind::PanicMessage { stderr_subslice }
6868
| DescriptionKind::ErrorStr {
6969
stderr_subslice, ..
7070
} => Some(*stderr_subslice),
@@ -76,7 +76,7 @@ impl<'a> DescriptionKind<'a> {
7676
pub fn stdout_subslice(&self) -> Option<ByteSubslice<'a>> {
7777
match self {
7878
DescriptionKind::Abort { .. } => None,
79-
DescriptionKind::StackTrace { .. } => None,
79+
DescriptionKind::PanicMessage { .. } => None,
8080
DescriptionKind::ErrorStr { .. } => None,
8181
DescriptionKind::ShouldPanic {
8282
stdout_subslice, ..
@@ -88,7 +88,7 @@ impl<'a> DescriptionKind<'a> {
8888
pub fn combined_subslice(&self) -> Option<ByteSubslice<'a>> {
8989
match self {
9090
DescriptionKind::Abort { .. } => None,
91-
DescriptionKind::StackTrace { stderr_subslice }
91+
DescriptionKind::PanicMessage { stderr_subslice }
9292
| DescriptionKind::ErrorStr {
9393
stderr_subslice, ..
9494
} => Some(*stderr_subslice),
@@ -147,7 +147,7 @@ impl fmt::Display for DescriptionKindDisplay<'_> {
147147
}
148148
Ok(())
149149
}
150-
DescriptionKind::StackTrace { stderr_subslice } => {
150+
DescriptionKind::PanicMessage { stderr_subslice } => {
151151
// Strip invalid XML characters.
152152
write!(f, "{}", String::from_utf8_lossy(stderr_subslice.slice))
153153
}
@@ -177,8 +177,8 @@ pub fn heuristic_extract_description<'a>(
177177
}
178178

179179
// Try the heuristic stack trace extraction first to try and grab more information first.
180-
if let Some(stderr_subslice) = heuristic_stack_trace(stderr) {
181-
return Some(DescriptionKind::StackTrace { stderr_subslice });
180+
if let Some(stderr_subslice) = heuristic_panic_message(stderr) {
181+
return Some(DescriptionKind::PanicMessage { stderr_subslice });
182182
}
183183
if let Some(stderr_subslice) = heuristic_error_str(stderr) {
184184
return Some(DescriptionKind::ErrorStr { stderr_subslice });
@@ -209,7 +209,7 @@ fn heuristic_should_panic(stdout: &[u8]) -> Option<ByteSubslice<'_>> {
209209
Some(ByteSubslice { slice: line, start })
210210
}
211211

212-
fn heuristic_stack_trace(stderr: &[u8]) -> Option<ByteSubslice<'_>> {
212+
fn heuristic_panic_message(stderr: &[u8]) -> Option<ByteSubslice<'_>> {
213213
let panicked_at_match = PANICKED_AT_REGEX.find(stderr)?;
214214
// If the previous line starts with "Error: ", grab it as well -- it contains the error with
215215
// result-based test failures.
@@ -247,7 +247,7 @@ fn heuristic_error_str(stderr: &[u8]) -> Option<ByteSubslice<'_>> {
247247
/// Given a slice, find the index of the point at which highlighting should end.
248248
///
249249
/// Returns a value in the range [0, slice.len()].
250-
pub(super) fn highlight_end(slice: &[u8]) -> usize {
250+
pub fn highlight_end(slice: &[u8]) -> usize {
251251
// We want to highlight the first two lines of the output.
252252
let mut iter = slice.find_iter(b"\n");
253253
match iter.next() {
@@ -417,7 +417,7 @@ some more text at the end, followed by some newlines"#,
417417
];
418418

419419
for (input, output) in tests {
420-
let extracted = heuristic_stack_trace(input.as_bytes())
420+
let extracted = heuristic_panic_message(input.as_bytes())
421421
.expect("stack trace should have been found");
422422
assert_eq!(
423423
DisplayWrapper(extracted.slice),

0 commit comments

Comments
 (0)