diff --git a/rust/private/rust.bzl b/rust/private/rust.bzl index 0697c6c2f8..157045cd63 100644 --- a/rust/private/rust.bzl +++ b/rust/private/rust.bzl @@ -598,6 +598,39 @@ def _rust_test_impl(ctx): env["CC_CODE_COVERAGE_SCRIPT"] = ctx.executable._collect_cc_coverage.path components = "{}/{}".format(ctx.label.workspace_root, ctx.label.package).split("/") env["CARGO_MANIFEST_DIR"] = "/".join([c for c in components if c]) + + if ctx.attr.junit: + test_bin_short = output.short_path + if test_bin_short.startswith("../"): + rust_test_bin_rloc = test_bin_short[len("../"):] + else: + rust_test_bin_rloc = ctx.workspace_name + "/" + test_bin_short + env["RUST_TEST_BIN"] = rust_test_bin_rloc + + # RUST_TEST_BIN above is a runfiles path and only resolves against + # RUNFILES_DIR. Coverage postprocessing may run with RUNFILES_DIR + # unset (--experimental_split_coverage_postprocessing), so also hand + # over the binary's execroot-relative path directly instead of making + # collect_coverage reconstruct it. + env["RUST_TEST_BIN_EXECROOT_PATH"] = output.path + + junit_runner = ctx.actions.declare_file(ctx.label.name + "_junit_runner" + toolchain.binary_ext) + ctx.actions.symlink( + output = junit_runner, + target_file = ctx.executable._junit_runner, + is_executable = True, + ) + + original_default_info = providers[0] + runner_runfiles = ctx.attr._junit_runner[DefaultInfo].default_runfiles + test_bin_runfiles = ctx.runfiles(files = [output]) + merged_runfiles = original_default_info.default_runfiles.merge(runner_runfiles).merge(test_bin_runfiles) + providers[0] = DefaultInfo( + files = original_default_info.files, + runfiles = merged_runfiles, + executable = junit_runner, + ) + providers.append(RunEnvironmentInfo( environment = env, inherited_environment = ctx.attr.env_inherit, @@ -996,6 +1029,27 @@ _RUST_TEST_ATTRS = { E.g. `bazel test //src:rust_test --test_arg=foo::test::test_fn`. """), ), + "junit": attr.bool( + default = True, + doc = dedent("""\ + If True (default), wrap the test binary with a JUnit XML runner that + parses libtest output and writes JUnit XML to `$XML_OUTPUT_FILE` when + run under `bazel test`. When `$XML_OUTPUT_FILE` is not set (e.g. + `bazel run`), the runner execs the test binary directly with zero + overhead. Set to False to bypass the wrapper entirely (useful for + debugging or attaching a debugger). + """), + ), + "_junit_runner": attr.label( + default = Label("//util/junit_runner"), + executable = True, + # Built for the exec platform, like the other test-support tools + # (process_wrapper, collect_coverage). This keeps the runner off the + # target configuration, so it doesn't inherit target-only settings such + # as a custom #[global_allocator] or cc_common.link, which it has no way + # to satisfy and which would otherwise fail to link. + cfg = "exec", + ), } | _COVERAGE_ATTRS | _EXPERIMENTAL_USE_CC_COMMON_LINK_ATTRS rust_library = rule( diff --git a/rust/runfiles/BUILD.bazel b/rust/runfiles/BUILD.bazel index 695fa918f9..98e1e64437 100644 --- a/rust/runfiles/BUILD.bazel +++ b/rust/runfiles/BUILD.bazel @@ -16,6 +16,12 @@ rust_test( name = "runfiles_test", crate = ":runfiles", data = ["data/sample.txt"], + # These tests inspect the process's own runfiles -- the manifest layout and + # the repo mapping that rlocation! resolves against. The junit wrapper runs + # the test as a child under the wrapper's runfiles, which is a different + # ambient environment than the one being asserted on, so the runfiles + # library's own tests need to run unwrapped. + junit = False, ) rust_doc( diff --git a/test/junit/BUILD.bazel b/test/junit/BUILD.bazel new file mode 100644 index 0000000000..1fed7c6ce8 --- /dev/null +++ b/test/junit/BUILD.bazel @@ -0,0 +1,14 @@ +load("//rust:defs.bzl", "rust_test") + +rust_test( + name = "junit_test", + srcs = ["lib.rs"], + edition = "2021", +) + +rust_test( + name = "no_junit_test", + srcs = ["lib.rs"], + edition = "2021", + junit = False, +) diff --git a/test/junit/lib.rs b/test/junit/lib.rs new file mode 100644 index 0000000000..38b2fa80fa --- /dev/null +++ b/test/junit/lib.rs @@ -0,0 +1,11 @@ +#[cfg(test)] +mod tests { + #[test] + fn test_passing() { + assert_eq!(2 + 2, 4); + } + + #[test] + #[ignore] + fn test_ignored() {} +} diff --git a/util/collect_coverage/collect_coverage.rs b/util/collect_coverage/collect_coverage.rs index 7fbe08a35b..f13ba09fde 100644 --- a/util/collect_coverage/collect_coverage.rs +++ b/util/collect_coverage/collect_coverage.rs @@ -129,7 +129,9 @@ fn main() { None => debug_log!("RUNFILES_DIR: not set (split coverage postprocessing)"), } - let coverage_output_file = coverage_dir.join("coverage.dat"); + let coverage_output_file = env::var("COVERAGE_OUTPUT_FILE") + .map(PathBuf::from) + .unwrap_or_else(|_| coverage_dir.join("coverage.dat")); let profdata_file = coverage_dir.join("coverage.profdata"); let llvm_cov_path = env::var("RUST_LLVM_COV").unwrap(); let llvm_profdata_path = env::var("RUST_LLVM_PROFDATA").unwrap(); @@ -141,15 +143,50 @@ fn main() { Some(ref rd) => find_metadata_file(&execroot, rd, &llvm_profdata_path), None => execroot.join(&llvm_profdata_path), }; - let test_binary = match runfiles_dir { - Some(ref rd) => find_test_binary(&execroot, rd), - None => { - let bin_dir = config_bin_dir(&execroot, &coverage_dir); - let test_binary = execroot - .join(bin_dir) - .join(env::var("TEST_BINARY").unwrap()); - debug_log!("Resolved TEST_BINARY to: {}", test_binary.display()); - test_binary + // When the JUnit runner wraps the test, TEST_BINARY points to the runner + // and RUST_TEST_BIN holds the actual instrumented binary that llvm-cov needs. + // + // RUST_TEST_BIN is a runfiles path (workspace-prefixed) and only resolves + // against RUNFILES_DIR. When RUNFILES_DIR is gone -- Bazel drops it under + // --experimental_split_coverage_postprocessing -- we need the binary's + // location relative to the execroot instead. Rather than try to rebuild + // that here by stitching the workspace-prefixed runfiles path onto the + // bin dir (which mislays the workspace segment and points at a file that + // isn't there), read RUST_TEST_BIN_EXECROOT_PATH, which the test rule + // already knows exactly. + let test_binary = if let Ok(rust_test_bin) = env::var("RUST_TEST_BIN") { + debug_log!("Using RUST_TEST_BIN: {}", rust_test_bin); + let execroot_path = || { + env::var("RUST_TEST_BIN_EXECROOT_PATH") + .map(|p| execroot.join(p)) + .unwrap_or_else(|_| { + let bin_dir = config_bin_dir(&execroot, &coverage_dir); + execroot.join(bin_dir).join(&rust_test_bin) + }) + }; + match runfiles_dir { + Some(ref rd) => { + let candidate = rd.join(&rust_test_bin); + if candidate.exists() { + candidate + } else { + debug_log!("RUST_TEST_BIN missing under RUNFILES_DIR; using execroot path"); + execroot_path() + } + } + None => execroot_path(), + } + } else { + match runfiles_dir { + Some(ref rd) => find_test_binary(&execroot, rd), + None => { + let bin_dir = config_bin_dir(&execroot, &coverage_dir); + let test_binary = execroot + .join(bin_dir) + .join(env::var("TEST_BINARY").unwrap()); + debug_log!("Resolved TEST_BINARY to: {}", test_binary.display()); + test_binary + } } }; let profraw_files: Vec = fs::read_dir(coverage_dir) @@ -199,7 +236,8 @@ fn main() { .arg("-format=lcov") .arg("-instr-profile") .arg(&profdata_file) - .arg("-ignore-filename-regex=.*external/.+") + .arg(r"-ignore-filename-regex=.*external[/\\].+") + .arg(r"-ignore-filename-regex=.*rustc[/\\].+") .arg("-ignore-filename-regex=/tmp/.+") .arg(format!("-path-equivalence=.,{}", execroot.display())) .arg(test_binary) @@ -234,7 +272,8 @@ fn main() { coverage_output_file, report_str .replace("#/proc/self/cwd/", "") - .replace(&execroot.display().to_string(), ""), + .replace(&execroot.display().to_string(), "") + .replace('\\', "/"), ) .unwrap(); diff --git a/util/junit_runner/BUILD.bazel b/util/junit_runner/BUILD.bazel new file mode 100644 index 0000000000..799ae41656 --- /dev/null +++ b/util/junit_runner/BUILD.bazel @@ -0,0 +1,20 @@ +load("//rust:defs.bzl", "rust_binary", "rust_test") + +rust_binary( + name = "junit_runner", + srcs = ["junit_runner.rs"], + edition = "2021", + rustc_flags = select({ + "@platforms//os:linux": ["-Cstrip=debuginfo"], + "@platforms//os:macos": ["-Cstrip=symbols"], + "//conditions:default": [], + }), + visibility = ["//visibility:public"], +) + +rust_test( + name = "junit_runner_test", + srcs = ["junit_runner.rs"], + edition = "2021", + junit = False, +) diff --git a/util/junit_runner/junit_runner.rs b/util/junit_runner/junit_runner.rs new file mode 100644 index 0000000000..122f6cd03b --- /dev/null +++ b/util/junit_runner/junit_runner.rs @@ -0,0 +1,1072 @@ +use std::collections::HashMap; +use std::env; +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +fn resolve_runfiles(rlocation_path: &str) -> PathBuf { + if let Ok(manifest) = env::var("RUNFILES_MANIFEST_FILE") { + if let Ok(contents) = fs::read_to_string(&manifest) { + let prefix = format!("{} ", rlocation_path); + for line in contents.lines() { + if let Some(abs_path) = line.strip_prefix(&prefix) { + let p = PathBuf::from(abs_path); + if p.exists() { + return p; + } + } + } + } + } + + if let Ok(dir) = env::var("RUNFILES_DIR") { + let candidate = PathBuf::from(&dir).join(rlocation_path); + if candidate.exists() { + return candidate; + } + } + + if let Ok(dir) = env::var("TEST_SRCDIR") { + let candidate = PathBuf::from(&dir).join(rlocation_path); + if candidate.exists() { + return candidate; + } + } + + eprintln!( + "ERROR: junit_runner: cannot resolve runfiles path: {}", + rlocation_path + ); + eprintln!( + " RUNFILES_MANIFEST_FILE={:?}", + env::var("RUNFILES_MANIFEST_FILE").ok() + ); + eprintln!(" RUNFILES_DIR={:?}", env::var("RUNFILES_DIR").ok()); + eprintln!(" TEST_SRCDIR={:?}", env::var("TEST_SRCDIR").ok()); + std::process::exit(1); +} + +fn exec_passthrough(test_bin: &PathBuf, args: &[String]) -> ! { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let err = Command::new(test_bin).args(args).exec(); + eprintln!("ERROR: junit_runner: exec failed: {}", err); + std::process::exit(1); + } + + #[cfg(not(unix))] + { + let status = Command::new(test_bin) + .args(args) + .status() + .unwrap_or_else(|e| { + eprintln!("ERROR: junit_runner: failed to spawn test binary: {}", e); + std::process::exit(1); + }); + std::process::exit(status.code().unwrap_or(1)); + } +} + +fn nocapture_in_args(args: &[String]) -> bool { + args.iter().any(|a| a == "--nocapture") +} + +/// True when libtest would run with output capture disabled: either the +/// `--nocapture` flag or the `RUST_TEST_NOCAPTURE` env var set to anything but +/// "0", matching libtest's own precedence. In that mode libtest interleaves +/// each test's own output into its result line, so the pretty output can no +/// longer be parsed back into per-test results and we step aside instead of +/// writing a broken report. +fn wants_nocapture(args: &[String]) -> bool { + nocapture_in_args(args) || matches!(env::var("RUST_TEST_NOCAPTURE"), Ok(v) if v != "0") +} + +#[derive(Debug, PartialEq)] +struct TestResult { + name: String, + status: String, +} + +/// The counts libtest prints on its `test result:` summary line. +#[derive(Debug, PartialEq)] +struct Summary { + passed: usize, + failed: usize, + ignored: usize, +} + +struct ParsedOutput { + results: Vec, + failures: HashMap, + suite_time: f64, + /// `None` if libtest never printed its summary line, which is our main + /// signal that the binary didn't finish running as a test harness. + summary: Option, +} + +/// A `---- stdout ----` (or `stderr`) banner that opens a failure +/// detail block; returns the test name it belongs to. +fn failure_header(line: &str) -> Option<&str> { + let inner = line.strip_prefix("---- ")?; + inner + .strip_suffix(" stdout ----") + .or_else(|| inner.strip_suffix(" stderr ----")) +} + +fn record_failure(failures: &mut HashMap, name: &str, lines: &[String]) { + let body = lines.join("\n").trim_end().to_string(); + // A test can have both stdout and stderr blocks; keep both. + match failures.get_mut(name) { + Some(existing) => { + existing.push('\n'); + existing.push_str(&body); + } + None => { + failures.insert(name.to_string(), body); + } + } +} + +fn parse_libtest_output(output: &str) -> ParsedOutput { + let mut results = Vec::new(); + let mut failures = HashMap::new(); + let mut current_failure: Option = None; + let mut failure_lines: Vec = Vec::new(); + let mut suite_time = 0.0; + let mut summary = None; + + for line in output.lines() { + // A new detail banner both opens a block and closes the previous one. + if let Some(name) = failure_header(line) { + if let Some(prev) = current_failure.take() { + record_failure(&mut failures, &prev, &failure_lines); + } + current_failure = Some(name.to_string()); + failure_lines.clear(); + continue; + } + + if let Some(name) = ¤t_failure { + // A detail block runs until the trailing `failures:` name list, the + // `test result:` summary, or a bare `----` terminator (older + // libtest). Everything else is part of the captured output/panic. + // Note we must not stop the block until here, or the summary line + // gets swallowed and we lose the run totals entirely. + if line == "failures:" || line.starts_with("test result: ") { + record_failure(&mut failures, name, &failure_lines); + current_failure = None; + failure_lines.clear(); + // fall through so the summary line is still parsed below + } else if line.starts_with("----") { + record_failure(&mut failures, name, &failure_lines); + current_failure = None; + failure_lines.clear(); + continue; + } else { + // Drop the blank line libtest prints right after the banner. + if !(line.trim().is_empty() && failure_lines.is_empty()) { + failure_lines.push(line.to_string()); + } + continue; + } + } + + // A per-test result: "test ... ok|FAILED|ignored|bench" + if line.starts_with("test ") && line.contains(" ... ") { + if let Some(result) = parse_test_result_line(line) { + results.push(result); + continue; + } + } + + // The run summary: "test result: ok. N passed; M failed; ..." + if line.starts_with("test result: ") { + if let Some(time) = parse_suite_time(line) { + suite_time = time; + } + if let Some(counts) = parse_suite_counts(line) { + summary = Some(counts); + } + } + } + + if let Some(name) = ¤t_failure { + record_failure(&mut failures, name, &failure_lines); + } + + ParsedOutput { + results, + failures, + suite_time, + summary, + } +} + +fn parse_test_result_line(line: &str) -> Option { + // Format: "test ... " + // The name can contain spaces in some edge cases, but typically doesn't. + // We split on " ... " to separate name from status. + let after_test = line.strip_prefix("test ")?; + let sep_pos = after_test.find(" ... ")?; + // `#[should_panic]` tests print as `test - should panic ... `, + // but the `---- stdout ----` failure banner uses the bare name. Strip + // the suffix so the testcase name is right and the failure-body lookup in + // build_junit_xml (keyed on the bare name) matches. + let raw_name = &after_test[..sep_pos]; + let name = raw_name.strip_suffix(" - should panic").unwrap_or(raw_name); + let rest = &after_test[sep_pos + " ... ".len()..]; + + // Status is the first word of rest. `#[ignore = "reason"]` prints + // `... ignored, `, so that first token carries a trailing comma; + // strip it before matching or the test is silently dropped from the report. + let status = rest.split_whitespace().next()?.trim_end_matches(','); + match status { + "ok" | "FAILED" | "ignored" | "bench" => Some(TestResult { + name: name.to_string(), + status: status.to_string(), + }), + _ => None, + } +} + +fn parse_suite_time(line: &str) -> Option { + // Format: "test result: ok. N passed; M failed; K ignored; ... finished in X.XXXs" + let finished_marker = "finished in "; + let pos = line.find(finished_marker)?; + let after = &line[pos + finished_marker.len()..]; + let time_str = after.strip_suffix('s')?; + time_str.parse::().ok() +} + +fn parse_suite_counts(line: &str) -> Option { + // "test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; ..." + // Each ';'-separated clause ends in "