Skip to content
Open
63 changes: 51 additions & 12 deletions rust/coverage/collect_rust_coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<PathBuf> = fs::read_dir(coverage_dir)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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();

Expand Down
76 changes: 76 additions & 0 deletions rust/private/rust.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,22 @@ def get_rust_test_flags(attr):

return rust_flags

def _is_junit_enabled(ctx):
"""Resolve whether the JUnit XML wrapper should be applied to this test.

Tri-state, mirroring `experimental_use_cc_common_link`:
* `experimental_junit = 1` -> always wrap this target.
* `experimental_junit = 0` -> never wrap this target.
* `experimental_junit = -1` (default) -> defer to the
`//rust/settings:experimental_emit_junit_xml` build setting.
"""
junit_attr = ctx.attr.experimental_junit
if junit_attr == 1:
return True
if junit_attr == 0:
return False
return ctx.attr._experimental_emit_junit_xml[BuildSettingInfo].value

def _rust_test_impl(ctx):
"""The implementation of the `rust_test` rule.

Expand Down Expand Up @@ -619,6 +635,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 _is_junit_enabled(ctx):
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,
Expand Down Expand Up @@ -1026,6 +1075,33 @@ _RUST_TEST_ATTRS = {
E.g. `bazel test //src:rust_test --test_arg=foo::test::test_fn`.
"""),
),
"experimental_junit": attr.int(
doc = (
"Experimental. Whether to wrap the test binary with a runner that emits a " +
"JUnit XML report parsed from the test's `libtest` output. " +
"Possible values: [-1, 0, 1]. " +
"-1 means use the value of the " +
"`--@rules_rust//rust/settings:experimental_emit_junit_xml` build setting to determine. " +
"0 means do not wrap the test (run it directly). " +
"1 means wrap the test and emit JUnit XML."
),
values = [-1, 0, 1],
default = -1,
),
"_experimental_emit_junit_xml": attr.label(
default = Label("//rust/settings:experimental_emit_junit_xml"),
doc = "The build setting consulted when `experimental_junit = -1`.",
),
"_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(
Expand Down
7 changes: 7 additions & 0 deletions rust/runfiles/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ 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. Force off even if the
# //rust/settings:experimental_emit_junit_xml build setting is enabled.
experimental_junit = 0,
)

rust_doc(
Expand Down
3 changes: 3 additions & 0 deletions rust/settings/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ load(
"default_allocator_library",
"error_format",
"experimental_compile_rustdoc_tests",
"experimental_emit_junit_xml",
"experimental_link_std_dylib",
"experimental_use_allocator_libraries_with_mangled_symbols",
"experimental_use_cc_common_link",
Expand Down Expand Up @@ -86,6 +87,8 @@ clippy_error_format()

experimental_compile_rustdoc_tests()

experimental_emit_junit_xml()

experimental_link_std_dylib()

experimental_use_cc_common_link()
Expand Down
17 changes: 17 additions & 0 deletions rust/settings/settings.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,23 @@ def experimental_link_std_dylib():
build_setting_default = False,
)

def experimental_emit_junit_xml():
"""A flag to control whether `rust_test` targets emit JUnit XML test reports.

When enabled, each `rust_test` is wrapped with a runner that parses the test
binary's `libtest` output and writes a JUnit XML report to the path named by the
`$XML_OUTPUT_FILE` environment variable that `bazel test` provides. When
`$XML_OUTPUT_FILE` is unset (for example under `bazel run`), the runner execs the
test binary directly and adds no overhead.

This flag is off by default. Individual targets may override it with the
`experimental_junit` attribute on `rust_test`.
"""
bool_flag(
name = "experimental_emit_junit_xml",
build_setting_default = False,
)

def experimental_use_sh_toolchain_for_bootstrap_process_wrapper():
"""A flag to control whether the shell path from a shell toolchain (`@bazel_tools//tools/sh:toolchain_type`) \
is embedded into the bootstrap process wrapper for the `.sh` file.
Expand Down
19 changes: 19 additions & 0 deletions test/junit/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
load("//rust:defs.bzl", "rust_test")

# Force the JUnit wrapper on regardless of the
# //rust/settings:experimental_emit_junit_xml build setting, so this coverage
# runs even when the (default-off) setting is not enabled.
rust_test(
name = "junit_test",
srcs = ["lib.rs"],
edition = "2021",
experimental_junit = 1,
)

# Force the JUnit wrapper off regardless of the build setting.
rust_test(
name = "no_junit_test",
srcs = ["lib.rs"],
edition = "2021",
experimental_junit = 0,
)
11 changes: 11 additions & 0 deletions test/junit/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#[cfg(test)]
mod tests {
#[test]
fn test_passing() {
assert_eq!(2 + 2, 4);
}

#[test]
#[ignore]
fn test_ignored() {}
}
4 changes: 4 additions & 0 deletions test/unit/emit_junit_xml/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
load(":emit_junit_xml_test.bzl", "emit_junit_xml_test_suite")

############################ UNIT TESTS #############################
emit_junit_xml_test_suite(name = "emit_junit_xml_test_suite")
117 changes: 117 additions & 0 deletions test/unit/emit_junit_xml/emit_junit_xml_test.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Analysis tests for the experimental JUnit XML wrapper on `rust_test`.

These verify which executable `rust_test` selects across the tri-state
`experimental_junit` attribute and the
`//rust/settings:experimental_emit_junit_xml` build setting. They run in the
analysis phase only (no test binary is executed): a wrapped target exposes the
`<name>_junit_runner` executable, an unwrapped target exposes the plain test
binary.
"""

load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts")
load("//rust:defs.bzl", "rust_test")

_JUNIT_RUNNER_MARKER = "_junit_runner"

_FLAG = str(Label("//rust/settings:experimental_emit_junit_xml"))

def _executable_basename(env):
tut = analysistest.target_under_test(env)
return tut[DefaultInfo].files_to_run.executable.basename

def _wrapped_test_impl(ctx):
env = analysistest.begin(ctx)
basename = _executable_basename(env)
asserts.true(
env,
_JUNIT_RUNNER_MARKER in basename,
"expected the JUnit runner to wrap the test, but the executable was {}".format(basename),
)
return analysistest.end(env)

def _unwrapped_test_impl(ctx):
env = analysistest.begin(ctx)
basename = _executable_basename(env)
asserts.false(
env,
_JUNIT_RUNNER_MARKER in basename,
"expected the test to run unwrapped, but the executable was {}".format(basename),
)
return analysistest.end(env)

wrapped_test = analysistest.make(_wrapped_test_impl)
unwrapped_test = analysistest.make(_unwrapped_test_impl)
wrapped_with_flag_test = analysistest.make(
_wrapped_test_impl,
config_settings = {_FLAG: True},
)
unwrapped_with_flag_test = analysistest.make(
_unwrapped_test_impl,
config_settings = {_FLAG: True},
)

def emit_junit_xml_test_suite(name):
"""Defines the JUnit-gating analysis-test suite.

Args:
name: name of the resulting `test_suite`.
"""
rust_test(
name = "attr_on",
srcs = ["lib.rs"],
edition = "2021",
experimental_junit = 1,
)
rust_test(
name = "attr_off",
srcs = ["lib.rs"],
edition = "2021",
experimental_junit = 0,
)
rust_test(
name = "attr_default",
srcs = ["lib.rs"],
edition = "2021",
)

# experimental_junit = 1 -> always wrapped, whatever the build setting is.
wrapped_test(
name = "attr_on_wraps_test",
target_under_test = ":attr_on",
)
wrapped_with_flag_test(
name = "attr_on_wraps_with_flag_test",
target_under_test = ":attr_on",
)

# experimental_junit = 0 -> never wrapped, whatever the build setting is.
unwrapped_test(
name = "attr_off_unwrapped_test",
target_under_test = ":attr_off",
)
unwrapped_with_flag_test(
name = "attr_off_unwrapped_with_flag_test",
target_under_test = ":attr_off",
)

# experimental_junit = -1 (default) -> defers to the build setting.
unwrapped_test(
name = "default_off_without_flag_test",
target_under_test = ":attr_default",
)
wrapped_with_flag_test(
name = "default_on_with_flag_test",
target_under_test = ":attr_default",
)

native.test_suite(
name = name,
tests = [
":attr_on_wraps_test",
":attr_on_wraps_with_flag_test",
":attr_off_unwrapped_test",
":attr_off_unwrapped_with_flag_test",
":default_off_without_flag_test",
":default_on_with_flag_test",
],
)
Loading