Skip to content

Commit 5a1932b

Browse files
authored
Add a cargo test coverage script (#13209)
* Add a `cargo test` coverage script Runs `cargo test ...` with LLVM instrumentation-based coverage enabled, merges the resulting `.profraw` files, and produces and HTML coverage report at `report/index.html`. * fix ci integration * Remove `+nightly` * fix ci again * fix ci
1 parent 4d171d0 commit 5a1932b

3 files changed

Lines changed: 256 additions & 0 deletions

File tree

.github/workflows/main.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,13 @@ jobs:
657657
env:
658658
RUSTFLAGS: "--cfg arc_try_new"
659659

660+
# Smoke test the coverage script.
661+
- run: rustup target add wasm32-unknown-unknown
662+
- run: rustup component add llvm-tools
663+
- run: ./scripts/coverage.rs --test wast gc
664+
- run: test -d report/
665+
- run: test -f report/index.html
666+
660667
# Ensure that fuzzers still build.
661668
#
662669
# Install the OCaml packages necessary for fuzz targets that use the

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@ testcase*.dna
3535
testcase*.json
3636
perf.data*
3737
miri-wast/
38+
report/

scripts/coverage.rs

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
#!/usr/bin/env -S cargo -Zscript -q --
2+
3+
//! Generate an LLVM source-based coverage report for `cargo test`.
4+
//!
5+
//! Usage: ./scripts/coverage.rs [cargo-test-args...]
6+
//!
7+
//! Runs `cargo test` with coverage instrumentation enabled, merges the
8+
//! resulting `.profraw` files, and produces an HTML coverage report at
9+
//! `report/index.html`.
10+
//!
11+
//! All arguments are forwarded to `cargo test`. For example, this runs only the
12+
//! tests under `tests/all/*` and the `.wast` tests whose names contain "gc":
13+
//!
14+
//! ./scripts/coverage.rs -p wasmtime-cli --test all --test wast gc
15+
16+
use std::env;
17+
use std::fs;
18+
use std::path::{Path, PathBuf};
19+
use std::process::{Command, Stdio};
20+
21+
fn main() {
22+
let root = find_repo_root();
23+
let args: Vec<_> = env::args().skip(1).collect();
24+
25+
let profraw_dir = root.join("target/coverage-profraw");
26+
let profdata_file = root.join("target/coverage.profdata");
27+
let report_dir = root.join("report");
28+
29+
if profraw_dir.exists() {
30+
fs::remove_dir_all(&profraw_dir).expect("failed to clean profraw dir");
31+
}
32+
fs::create_dir_all(&profraw_dir).expect("failed to create profraw dir");
33+
34+
let llvm_profdata = find_llvm_tool("llvm-profdata");
35+
let llvm_cov = find_llvm_tool("llvm-cov");
36+
37+
let mut rustflags = env::var("RUSTFLAGS").unwrap_or_default();
38+
if !rustflags.is_empty() {
39+
rustflags.push(' ');
40+
}
41+
rustflags.push_str("-C instrument-coverage");
42+
43+
let profraw_pattern = profraw_dir.join("%m_%p.profraw");
44+
45+
run_tests(&root, &args, &rustflags, &profraw_pattern);
46+
let binaries = discover_test_binaries(&root, &args, &rustflags, &profraw_pattern);
47+
merge_profraw(&llvm_profdata, &profraw_dir, &profdata_file);
48+
generate_report(&llvm_cov, &binaries, &profdata_file, &report_dir);
49+
}
50+
51+
fn run_tests(root: &Path, args: &[String], rustflags: &str, profraw_pattern: &Path) {
52+
eprintln!("=== Running cargo test with coverage ===");
53+
let status = Command::new("cargo")
54+
.arg("test")
55+
.args(args)
56+
.env("RUSTFLAGS", rustflags)
57+
.env("LLVM_PROFILE_FILE", profraw_pattern)
58+
.current_dir(root)
59+
.status()
60+
.expect("failed to run cargo test");
61+
if !status.success() {
62+
eprintln!("cargo test failed with {status}");
63+
std::process::exit(status.code().unwrap_or(1));
64+
}
65+
}
66+
67+
fn discover_test_binaries(
68+
root: &Path,
69+
args: &[String],
70+
rustflags: &str,
71+
profraw_pattern: &Path,
72+
) -> Vec<String> {
73+
// We need `--no-run --message-format=json` to be cargo flags, not test
74+
// binary flags. Split at `--` so they're inserted before it.
75+
eprintln!("=== Discovering test binaries ===");
76+
let cargo_args: Vec<_> = args.iter().take_while(|a| *a != "--").collect();
77+
let output = Command::new("cargo")
78+
.arg("test")
79+
.args(&cargo_args)
80+
.arg("--no-run")
81+
.arg("--message-format=json")
82+
.env("RUSTFLAGS", rustflags)
83+
.env("LLVM_PROFILE_FILE", profraw_pattern)
84+
.current_dir(root)
85+
.stderr(Stdio::inherit())
86+
.output()
87+
.expect("failed to run cargo test --no-run");
88+
if !output.status.success() {
89+
eprintln!("cargo test --no-run failed with {}", output.status);
90+
std::process::exit(output.status.code().unwrap_or(1));
91+
}
92+
93+
let jq_output = Command::new("jq")
94+
.arg("-r")
95+
.arg(r#"select(.profile.test == true) | .filenames[]"#)
96+
.stdin(Stdio::piped())
97+
.stdout(Stdio::piped())
98+
.stderr(Stdio::inherit())
99+
.spawn()
100+
.and_then(|mut child| {
101+
use std::io::Write;
102+
child.stdin.take().unwrap().write_all(&output.stdout)?;
103+
child.wait_with_output()
104+
})
105+
.expect("failed to run jq — is it installed?");
106+
if !jq_output.status.success() {
107+
eprintln!("jq failed with {}", jq_output.status);
108+
std::process::exit(1);
109+
}
110+
111+
let binaries: Vec<_> = String::from_utf8_lossy(&jq_output.stdout)
112+
.lines()
113+
.filter(|f| !f.contains("dSYM"))
114+
.map(|s| s.to_string())
115+
.collect();
116+
117+
if binaries.is_empty() {
118+
eprintln!("error: no test binaries found");
119+
std::process::exit(1);
120+
}
121+
for b in &binaries {
122+
eprintln!(" found binary: {b}");
123+
}
124+
binaries
125+
}
126+
127+
fn merge_profraw(llvm_profdata: &Path, profraw_dir: &Path, profdata_file: &Path) {
128+
eprintln!("=== Merging profraw files ===");
129+
let profraw_files: Vec<_> = fs::read_dir(profraw_dir)
130+
.expect("failed to read profraw dir")
131+
.filter_map(|e| {
132+
let path = e.ok()?.path();
133+
if path.extension().is_some_and(|ext| ext == "profraw") {
134+
Some(path)
135+
} else {
136+
None
137+
}
138+
})
139+
.collect();
140+
141+
if profraw_files.is_empty() {
142+
eprintln!(
143+
"error: no .profraw files found in {}",
144+
profraw_dir.display()
145+
);
146+
std::process::exit(1);
147+
}
148+
eprintln!(" merging {} profraw files", profraw_files.len());
149+
150+
let mut cmd = Command::new(llvm_profdata);
151+
cmd.arg("merge").arg("-sparse");
152+
for f in &profraw_files {
153+
cmd.arg(f);
154+
}
155+
cmd.arg("-o").arg(profdata_file);
156+
let status = cmd.status().expect("failed to run llvm-profdata");
157+
if !status.success() {
158+
eprintln!("llvm-profdata merge failed with {status}");
159+
std::process::exit(1);
160+
}
161+
}
162+
163+
fn generate_report(llvm_cov: &Path, binaries: &[String], profdata_file: &Path, report_dir: &Path) {
164+
eprintln!("=== Generating HTML coverage report ===");
165+
if report_dir.exists() {
166+
fs::remove_dir_all(report_dir).expect("failed to clean report dir");
167+
}
168+
169+
let mut cmd = Command::new(llvm_cov);
170+
cmd.arg("show")
171+
.arg("--format=html")
172+
.arg(format!("--output-dir={}", report_dir.display()))
173+
.arg("--ignore-filename-regex=/.cargo/registry")
174+
.arg("--ignore-filename-regex=/rustc/")
175+
.arg("--ignore-filename-regex=/.rustup/")
176+
.arg(format!("--instr-profile={}", profdata_file.display()))
177+
.arg("--show-line-counts-or-regions")
178+
.arg("--show-instantiations")
179+
.arg("--show-region-summary")
180+
.arg("--show-branch-summary");
181+
182+
cmd.arg(&binaries[0]);
183+
for b in &binaries[1..] {
184+
cmd.arg("--object").arg(b);
185+
}
186+
187+
if has_command("rustfilt") {
188+
cmd.arg("-Xdemangler=rustfilt");
189+
}
190+
191+
let status = cmd.status().expect("failed to run llvm-cov");
192+
if !status.success() {
193+
eprintln!("llvm-cov show failed with {status}");
194+
std::process::exit(1);
195+
}
196+
197+
eprintln!(
198+
"=== Coverage report written to {}/index.html ===",
199+
report_dir.display()
200+
);
201+
}
202+
203+
fn find_repo_root() -> PathBuf {
204+
let mut dir = env::current_dir().expect("failed to get cwd");
205+
loop {
206+
if dir.join("Cargo.toml").exists() && dir.join("crates").exists() {
207+
return dir;
208+
}
209+
if !dir.pop() {
210+
eprintln!("error: could not find wasmtime repo root");
211+
std::process::exit(1);
212+
}
213+
}
214+
}
215+
216+
fn find_llvm_tool(name: &str) -> PathBuf {
217+
let output = Command::new("rustc")
218+
.arg("--print")
219+
.arg("sysroot")
220+
.output()
221+
.expect("failed to run rustc --print sysroot");
222+
let sysroot = String::from_utf8(output.stdout)
223+
.expect("non-utf8 sysroot")
224+
.trim()
225+
.to_string();
226+
227+
let rustlib = Path::new(&sysroot).join("lib").join("rustlib");
228+
if let Ok(entries) = fs::read_dir(&rustlib) {
229+
for entry in entries.flatten() {
230+
let candidate = entry.path().join("bin").join(name);
231+
if candidate.exists() {
232+
return candidate;
233+
}
234+
}
235+
}
236+
237+
eprintln!("warning: {name} not found in rustup sysroot, trying PATH");
238+
PathBuf::from(name)
239+
}
240+
241+
fn has_command(name: &str) -> bool {
242+
Command::new(name)
243+
.arg("--version")
244+
.stdout(Stdio::null())
245+
.stderr(Stdio::null())
246+
.status()
247+
.is_ok_and(|s| s.success())
248+
}

0 commit comments

Comments
 (0)