Skip to content

Commit 7f1f439

Browse files
committed
Support test results output postprocessing by a child process.
Add the following two optional flags to `libtest` (rustc's built-in unit-test framework), in order to support postprocessing of the test results using a separate executable: * `--output_postprocess_executable [PATH]` * `--output_postprocess_args [ARGUMENT]` (can be repeated.) If you don't pass `--output_postprocess_executable [PATH]`, the behavior stays the same as before this commit. That is, the test results are sent to stdout. If you pass `--output_postprocess_executable [PATH]`, `libtest` will 1. Spawn a child process from the executable binary (aka *postprocessor*) at the given path. 2. Pass the arguments from the `--output_postprocess_args [ARGUMENT]` flags (if any) to the child process. If `--output_postprocess_args` was used multiple times, all listed arguments will be passed in the original order. 3. Propagate the environment variables to the child process. The *postprocessor* executable is expected to wait for the end of input (EOF) and then terminate. Usage example #1: Filter lines of the test results ```shell $ LD_LIBRARY_PATH=$(pwd) ./test-05daf44cb501aee6 --output_postprocess_executable=/usr/bin/grep --output_postprocess_args="test result" test result: ok. 59 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.31s ``` Usage example rust-lang#2: Save test results into a file ```shell $ LD_LIBRARY_PATH=$(pwd) ./test-05daf44cb501aee6 --output_postprocess_executable=/usr/bin/sh --output_postprocess_args=-c --output_postprocess_args="cat > /tmp/output.txt" ``` Usage example rust-lang#3: Save test results into a file while keeping the command line output ```shell $ LD_LIBRARY_PATH=$(pwd) ./test-05daf44cb501aee6 --output_postprocess_executable=/usr/bin/tee --output_postprocess_args="/tmp/output.txt" running 60 tests ... ``` Usage example rust-lang#4: Prepend every line of test results with the value of an environment variable (to demonstrate environment variable propagation) ```shell $ LOG_PREFIX=">>>" LD_LIBRARY_PATH=$(pwd) ./test-05daf44cb501aee6 --output_postprocess_executable=/usr/bin/sh --output_postprocess_args=-c --output_postprocess_args="sed s/^/\$LOG_PREFIX/" >>> >>>running 60 tests ... ``` Usage example rust-lang#5: Change format of JSON output (using https://jqlang.github.io/jq/) ```shell $ LD_LIBRARY_PATH=$(pwd) ./test-05daf44cb501aee6 -Zunstable-options --format=json --output_postprocess_executable=/usr/bin/jq ``` Usage example rust-lang#6: Print test execution time in machine-readable format ```shell $ LD_LIBRARY_PATH=$(pwd) ./test-05daf44cb501aee6 -Zunstable-options --format=json --output_postprocess_executable=/usr/bin/jq --output_postprocess_args=".exec_time | numbers" 0.234317996 ``` Rationale for adding this functionality: * Bazel (build system) doesn't provide a way to process output from a binary (in this case, Rust test binary's output) other using a wrapper binary. However, using a wrapper binary potentially breaks debugging, because Bazel would suggest to debug the wrapper binary rather than the Rust test itself. * See bazelbuild/rules_rust#1303. * Cargo is not used in Rust Bazel rules. * Although we could wait for rust-lang#96290 and then modify Rust Bazel rules to pass `--logfile` on the command line to provisionally unblock bazelbuild/rules_rust#1303, that solution still wouldn't allow advanced test results postprocessing such as changing JSON/XML schema and injecting extra JUnit properties. * Due to limitations of Rust libtest formatters, Rust developers often use a separate tool to postprocess the test results output (see comments to rust-lang#85563). * Examples of existing postprocessing tools: * https://crates.io/crates/cargo2junit * https://crates.io/crates/gitlab-report * https://crates.io/crates/cargo-suity * For these use cases, it might be helpful to use the new flags `--output_postprocess_executable`, `--output_postprocess_args` instead of piping the test results explicitly, e.g. to more reliably separate test results from other output. Rationale for implementation details: * Use platform-dependent scripts (.sh, .cmd) because it doesn't seem to be possible to enable unstable feature `bindeps` (https://rust-lang.github.io/rfcs/3028-cargo-binary-dependencies.html) in "x.py" by default. * Here's a preexisting test that also uses per-platform specialization: `library/std/src/process/tests.rs`.
1 parent 52f8aec commit 7f1f439

File tree

8 files changed

+273
-35
lines changed

8 files changed

+273
-35
lines changed

Cargo.lock

+1
Original file line numberDiff line numberDiff line change
@@ -5445,6 +5445,7 @@ dependencies = [
54455445
"libc",
54465446
"panic_abort",
54475447
"panic_unwind",
5448+
"rand",
54485449
"std",
54495450
]
54505451

library/test/Cargo.toml

+3
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ core = { path = "../core" }
1010
panic_unwind = { path = "../panic_unwind" }
1111
panic_abort = { path = "../panic_abort" }
1212
libc = { version = "0.2.150", default-features = false }
13+
14+
[dev-dependencies]
15+
rand = { version = "0.8.5" }

library/test/src/cli.rs

+30
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ pub struct TestOpts {
3131
/// abort as soon as possible.
3232
pub fail_fast: bool,
3333
pub options: Options,
34+
/// The test results text output may optionally be piped into the given executable running as
35+
/// a child process. The child process inherits the environment variables passed to the test
36+
/// binary.
37+
///
38+
/// If this is unset, the test results will be written to stdout.
39+
pub output_postprocess_executable: Option<PathBuf>,
40+
pub output_postprocess_args: Vec<String>,
3441
}
3542

3643
impl TestOpts {
@@ -148,6 +155,18 @@ fn optgroups() -> getopts::Options {
148155
"shuffle-seed",
149156
"Run tests in random order; seed the random number generator with SEED",
150157
"SEED",
158+
)
159+
.optopt(
160+
"",
161+
"output_postprocess_executable",
162+
"Postprocess test results using the given executable",
163+
"PATH",
164+
)
165+
.optmulti(
166+
"",
167+
"output_postprocess_args",
168+
"When --output_postprocess_executable is set, pass the given command line arguments to the executable",
169+
"ARGUMENT",
151170
);
152171
opts
153172
}
@@ -281,6 +300,9 @@ fn parse_opts_impl(matches: getopts::Matches) -> OptRes {
281300

282301
let options = Options::new().display_output(matches.opt_present("show-output"));
283302

303+
let output_postprocess_executable = get_output_postprocess_executable(&matches)?;
304+
let output_postprocess_args = matches.opt_strs("output_postprocess_args");
305+
284306
let test_opts = TestOpts {
285307
list,
286308
filters,
@@ -301,6 +323,8 @@ fn parse_opts_impl(matches: getopts::Matches) -> OptRes {
301323
time_options,
302324
options,
303325
fail_fast: false,
326+
output_postprocess_executable,
327+
output_postprocess_args,
304328
};
305329

306330
Ok(test_opts)
@@ -493,3 +517,9 @@ fn get_log_file(matches: &getopts::Matches) -> OptPartRes<Option<PathBuf>> {
493517

494518
Ok(logfile)
495519
}
520+
521+
fn get_output_postprocess_executable(matches: &getopts::Matches) -> OptPartRes<Option<PathBuf>> {
522+
let executable = matches.opt_str("output_postprocess_executable").map(|s| PathBuf::from(&s));
523+
524+
Ok(executable)
525+
}

library/test/src/console.rs

+75-35
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use std::fs::File;
44
use std::io;
55
use std::io::prelude::Write;
6+
use std::process::{Command, Stdio};
67
use std::time::Instant;
78

89
use super::{
@@ -289,50 +290,89 @@ fn on_test_event(
289290
}
290291

291292
/// A simple console test runner.
292-
/// Runs provided tests reporting process and results to the stdout.
293+
/// Runs provided tests reporting process and results.
294+
///
295+
/// The results may optionally be piped to the specified postprocessor binary, otherwise written to stdout.
293296
pub fn run_tests_console(opts: &TestOpts, tests: Vec<TestDescAndFn>) -> io::Result<bool> {
294-
let output = match term::stdout() {
295-
None => OutputLocation::Raw(io::stdout()),
296-
Some(t) => OutputLocation::Pretty(t),
297+
let mut postprocessor = match &opts.output_postprocess_executable {
298+
None => None,
299+
Some(postprocess_executable) => Some(
300+
Command::new(&postprocess_executable)
301+
.args(&opts.output_postprocess_args)
302+
.stdin(Stdio::piped())
303+
.spawn()
304+
.expect("failed to execute test output postprocessor"),
305+
),
297306
};
298307

299-
let max_name_len = tests
300-
.iter()
301-
.max_by_key(|t| len_if_padded(t))
302-
.map(|t| t.desc.name.as_slice().len())
303-
.unwrap_or(0);
308+
let write_result;
309+
{
310+
// This inner block is to make sure `child_stdin` is dropped, so that the pipe
311+
// and the postprocessor's stdin is closed to notify it of EOF.
312+
//
313+
// Otherwise, the postprocessor may be stuck waiting for more input.
314+
315+
let mut child_stdin;
316+
let mut host_stdout;
317+
let output = match (&mut postprocessor, term::stdout()) {
318+
(Some(child), _) => OutputLocation::Raw({
319+
child_stdin = child.stdin.take().unwrap();
320+
&mut child_stdin as &mut dyn Write
321+
}),
322+
(None, None) => OutputLocation::Raw({
323+
host_stdout = io::stdout();
324+
&mut host_stdout as &mut dyn Write
325+
}),
326+
(None, Some(t)) => OutputLocation::Pretty(t),
327+
};
304328

305-
let is_multithreaded = opts.test_threads.unwrap_or_else(get_concurrency) > 1;
329+
let max_name_len = tests
330+
.iter()
331+
.max_by_key(|t| len_if_padded(t))
332+
.map(|t| t.desc.name.as_slice().len())
333+
.unwrap_or(0);
334+
335+
let is_multithreaded = opts.test_threads.unwrap_or_else(get_concurrency) > 1;
336+
337+
let mut out: Box<dyn OutputFormatter> = match opts.format {
338+
OutputFormat::Pretty => Box::new(PrettyFormatter::new(
339+
output,
340+
opts.use_color(),
341+
max_name_len,
342+
is_multithreaded,
343+
opts.time_options,
344+
)),
345+
OutputFormat::Terse => Box::new(TerseFormatter::new(
346+
output,
347+
opts.use_color(),
348+
max_name_len,
349+
is_multithreaded,
350+
)),
351+
OutputFormat::Json => Box::new(JsonFormatter::new(output)),
352+
OutputFormat::Junit => Box::new(JunitFormatter::new(output)),
353+
};
354+
let mut st = ConsoleTestState::new(opts)?;
306355

307-
let mut out: Box<dyn OutputFormatter> = match opts.format {
308-
OutputFormat::Pretty => Box::new(PrettyFormatter::new(
309-
output,
310-
opts.use_color(),
311-
max_name_len,
312-
is_multithreaded,
313-
opts.time_options,
314-
)),
315-
OutputFormat::Terse => {
316-
Box::new(TerseFormatter::new(output, opts.use_color(), max_name_len, is_multithreaded))
317-
}
318-
OutputFormat::Json => Box::new(JsonFormatter::new(output)),
319-
OutputFormat::Junit => Box::new(JunitFormatter::new(output)),
320-
};
321-
let mut st = ConsoleTestState::new(opts)?;
356+
// Prevent the usage of `Instant` in some cases:
357+
// - It's currently not supported for wasm targets.
358+
// - We disable it for miri because it's not available when isolation is enabled.
359+
let is_instant_supported = !cfg!(target_family = "wasm") && !cfg!(miri);
322360

323-
// Prevent the usage of `Instant` in some cases:
324-
// - It's currently not supported for wasm targets.
325-
// - We disable it for miri because it's not available when isolation is enabled.
326-
let is_instant_supported =
327-
!cfg!(target_family = "wasm") && !cfg!(target_os = "zkvm") && !cfg!(miri);
361+
let start_time = is_instant_supported.then(Instant::now);
362+
run_tests(opts, tests, |x| on_test_event(&x, &mut st, &mut *out))?;
363+
st.exec_time = start_time.map(|t| TestSuiteExecTime(t.elapsed()));
328364

329-
let start_time = is_instant_supported.then(Instant::now);
330-
run_tests(opts, tests, |x| on_test_event(&x, &mut st, &mut *out))?;
331-
st.exec_time = start_time.map(|t| TestSuiteExecTime(t.elapsed()));
365+
assert!(opts.fail_fast || st.current_test_count() == st.total);
332366

333-
assert!(opts.fail_fast || st.current_test_count() == st.total);
367+
write_result = out.write_run_finish(&st);
368+
}
369+
370+
if let Some(mut child) = postprocessor {
371+
let status = child.wait().expect("failed to get test output postprocessor status");
372+
assert!(status.success());
373+
}
334374

335-
out.write_run_finish(&st)
375+
write_result
336376
}
337377

338378
// Calculates padding for given test description.
+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
@REM A very basic test output postprocessor. Used in `test_output_postprocessing()`.
2+
3+
@echo off
4+
5+
if [%TEST_POSTPROCESSOR_OUTPUT_FILE%] == [] (
6+
echo Required environment variable TEST_POSTPROCESSOR_OUTPUT_FILE is not set.
7+
cmd /C exit /B 1
8+
)
9+
10+
@REM Forward script's input into file.
11+
find /v "" > %TEST_POSTPROCESSOR_OUTPUT_FILE%
12+
13+
@REM Log every command line argument into the same file.
14+
:start
15+
if [%1] == [] goto done
16+
echo %~1>> %TEST_POSTPROCESSOR_OUTPUT_FILE%
17+
shift
18+
goto start
19+
:done
+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/bin/bash
2+
#
3+
# A very basic test output postprocessor. Used in `test_output_postprocessing()`.
4+
5+
if [ -z "$TEST_POSTPROCESSOR_OUTPUT_FILE" ]
6+
then
7+
echo "Required environment variable TEST_POSTPROCESSOR_OUTPUT_FILE is not set."
8+
exit 1
9+
fi
10+
11+
# Forward script's input into file.
12+
cat /dev/stdin > "$TEST_POSTPROCESSOR_OUTPUT_FILE"
13+
14+
# Log every command line argument into the same file.
15+
for i in "$@"
16+
do
17+
echo "$i" >> "$TEST_POSTPROCESSOR_OUTPUT_FILE"
18+
done

library/test/src/tests.rs

+125
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
use rand::RngCore;
2+
use std::fs;
3+
use std::path::PathBuf;
4+
15
use super::*;
26

37
use crate::{
@@ -35,10 +39,42 @@ impl TestOpts {
3539
time_options: None,
3640
options: Options::new(),
3741
fail_fast: false,
42+
output_postprocess_executable: None,
43+
output_postprocess_args: vec![],
44+
}
45+
}
46+
}
47+
48+
// These implementations of TempDir and tmpdir are forked from rust/library/std/src/sys_common/io.rs.
49+
struct TempDir(PathBuf);
50+
51+
impl TempDir {
52+
fn join(&self, path: &str) -> PathBuf {
53+
let TempDir(ref p) = *self;
54+
p.join(path)
55+
}
56+
}
57+
58+
impl Drop for TempDir {
59+
fn drop(&mut self) {
60+
let TempDir(ref p) = *self;
61+
let result = fs::remove_dir_all(p);
62+
// Avoid panicking while panicking as this causes the process to
63+
// immediately abort, without displaying test results.
64+
if !thread::panicking() {
65+
result.unwrap();
3866
}
3967
}
4068
}
4169

70+
fn tmpdir() -> TempDir {
71+
let p = env::temp_dir();
72+
let mut r = rand::thread_rng();
73+
let ret = p.join(&format!("rust-{}", r.next_u32()));
74+
fs::create_dir(&ret).unwrap();
75+
TempDir(ret)
76+
}
77+
4278
fn one_ignored_one_unignored_test() -> Vec<TestDescAndFn> {
4379
vec![
4480
TestDescAndFn {
@@ -478,6 +514,25 @@ fn parse_include_ignored_flag() {
478514
assert_eq!(opts.run_ignored, RunIgnored::Yes);
479515
}
480516

517+
#[test]
518+
fn parse_output_postprocess() {
519+
let args = vec![
520+
"progname".to_string(),
521+
"filter".to_string(),
522+
"--output_postprocess_executable".to_string(),
523+
"/tmp/postprocess.sh".to_string(),
524+
"--output_postprocess_args".to_string(),
525+
"--test1=a".to_string(),
526+
"--output_postprocess_args=--test2=b".to_string(),
527+
];
528+
let opts = parse_opts(&args).unwrap().unwrap();
529+
assert_eq!(opts.output_postprocess_executable, Some(PathBuf::from("/tmp/postprocess.sh")));
530+
assert_eq!(
531+
opts.output_postprocess_args,
532+
vec!["--test1=a".to_string(), "--test2=b".to_string()]
533+
);
534+
}
535+
481536
#[test]
482537
pub fn filter_for_ignored_option() {
483538
// When we run ignored tests the test filter should filter out all the
@@ -922,3 +977,73 @@ fn test_dyn_bench_returning_err_fails_when_run_as_test() {
922977
let result = rx.recv().unwrap().result;
923978
assert_eq!(result, TrFailed);
924979
}
980+
981+
#[test]
982+
fn test_output_postprocessing() {
983+
let desc = TestDescAndFn {
984+
desc: TestDesc {
985+
name: StaticTestName("whatever"),
986+
ignore: false,
987+
ignore_message: None,
988+
source_file: "",
989+
start_line: 0,
990+
start_col: 0,
991+
end_line: 0,
992+
end_col: 0,
993+
should_panic: ShouldPanic::No,
994+
compile_fail: false,
995+
no_run: false,
996+
test_type: TestType::Unknown,
997+
},
998+
testfn: DynTestFn(Box::new(move || Ok(()))),
999+
};
1000+
1001+
let mut test_postprocessor: PathBuf = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1002+
if cfg!(target_os = "windows") {
1003+
test_postprocessor.push("src/testdata/postprocess.cmd");
1004+
} else {
1005+
test_postprocessor.push("src/testdata/postprocess.sh");
1006+
}
1007+
1008+
let tmpdir = tmpdir();
1009+
let output_path = &tmpdir.join("output.txt");
1010+
1011+
std::env::set_var("TEST_POSTPROCESSOR_OUTPUT_FILE", output_path);
1012+
1013+
let opts = TestOpts {
1014+
run_tests: true,
1015+
output_postprocess_executable: Some(test_postprocessor),
1016+
output_postprocess_args: vec!["--test1=a".to_string(), "--test2=b".to_string()],
1017+
format: OutputFormat::Json,
1018+
..TestOpts::new()
1019+
};
1020+
run_tests_console(&opts, vec![desc]).unwrap();
1021+
1022+
// Read output and replace the decimal value at `"exec_time": 0.000084974` to make the text deterministic.
1023+
// This replacement could be done easier with a regex, but `std` has no regex.
1024+
let mut contents =
1025+
fs::read_to_string(output_path).expect("Test postprocessor did not create file");
1026+
let replace_trigger = r#""exec_time": "#;
1027+
let replace_start =
1028+
contents.find(replace_trigger).expect("exec_time not found in the output JSON")
1029+
+ replace_trigger.len();
1030+
let replace_end = replace_start
1031+
+ contents[replace_start..]
1032+
.find(' ')
1033+
.expect("No space found after the decimal value for exec_time");
1034+
contents.replace_range(replace_start..replace_end, "AAA.BBB");
1035+
1036+
// Split output at line breaks to make the comparison platform-agnostic regarding newline style.
1037+
let contents_lines = contents.as_str().lines().collect::<Vec<&str>>();
1038+
1039+
let expected_lines = vec![
1040+
r#"{ "type": "suite", "event": "started", "test_count": 1 }"#,
1041+
r#"{ "type": "test", "event": "started", "name": "whatever" }"#,
1042+
r#"{ "type": "test", "name": "whatever", "event": "ok" }"#,
1043+
r#"{ "type": "suite", "event": "ok", "passed": 1, "failed": 0, "ignored": 0, "measured": 0, "filtered_out": 0, "exec_time": AAA.BBB }"#,
1044+
r#"--test1=a"#,
1045+
r#"--test2=b"#,
1046+
];
1047+
1048+
assert_eq!(contents_lines, expected_lines);
1049+
}

0 commit comments

Comments
 (0)