Skip to content

Commit d03f25c

Browse files
Add code coverage infrastructure (#537)
## Summary - Add `cargo llvm-cov` coverage infrastructure with two modes: - `just coverage` — karva crate only, ~70% line coverage, HTML output - `just coverage-full` — all crates including `karva_test_semantic` via instrumented worker binary, ~86% line coverage - Add coverage CI job to the existing workflow (same trigger conditions as `cargo test`) - Add `KARVA_WORKER_BINARY` env var override for worker binary discovery - Add private `__KARVA_COVERAGE` env var to gate coverage-only venv activation in the embedded Python interpreter The coverage-specific code (venv activation for the embedded PyO3 interpreter) is completely gated behind `__KARVA_COVERAGE` and is a no-op during normal test runs. All 685 tests continue to pass unchanged. ## Test plan - [x] `just test` — 685/685 tests pass (no behavior change) - [x] `just coverage` — 457/457 tests pass, HTML report generated - [x] `just coverage-full` — 457/457 tests pass, 86% line coverage across all crates - [x] `uvx prek run -a` — all pre-commit checks pass
1 parent 991988e commit d03f25c

File tree

7 files changed

+254
-4
lines changed

7 files changed

+254
-4
lines changed

.github/workflows/ci.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,53 @@ jobs:
126126
run: |
127127
cargo nextest run
128128
129+
coverage:
130+
name: "coverage"
131+
132+
runs-on: ubuntu-latest
133+
134+
needs: determine_changes
135+
if: ${{ (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
136+
137+
steps:
138+
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
139+
with:
140+
persist-credentials: false
141+
142+
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
143+
144+
- name: "Install Rust toolchain"
145+
run: rustup component add llvm-tools-preview
146+
147+
- name: "Install tools"
148+
uses: taiki-e/install-action@a416ddeedbd372e614cc1386e8b642692f66865e # v2.57.1
149+
with:
150+
tool: cargo-llvm-cov,cargo-nextest,just,maturin
151+
152+
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
153+
with:
154+
python-version: "3.10"
155+
156+
- name: Setup Python
157+
uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4.9.1
158+
id: setup-python
159+
with:
160+
python-version: "3.10"
161+
162+
- name: "Run coverage"
163+
env:
164+
PYO3_PYTHON: ${{ steps.setup-python.outputs.python-path }}
165+
KARVA_MAX_PARALLELISM: 2
166+
VIRTUAL_ENV: ""
167+
PYTHON_VERSION: "3.10"
168+
run: just coverage
169+
170+
- name: "Upload coverage report"
171+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
172+
with:
173+
name: coverage-report
174+
path: target/coverage-html/
175+
129176
build-docs:
130177
name: "Build docs"
131178

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,8 @@ uv.lock
1212
# Compiled Python files
1313
*.so
1414

15+
# Coverage profile data
16+
*.profraw
17+
1518
# prek prettier cache
1619
node_modules

crates/karva/tests/it/common/mod.rs

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,30 @@ impl TestContext {
132132
.unwrap_or_else(|e| panic!("Failed to read file `{full_path}`: {e}"))
133133
}
134134

135+
/// Create a base `Command` for the karva binary.
136+
///
137+
/// When running under `cargo test` / `cargo llvm-cov`, uses the cargo-built
138+
/// binary (instrumented for coverage) and sets env vars so the worker
139+
/// binary and Python packages are found correctly.
140+
fn karva_command(&self) -> Command {
141+
if let Some(binary) = cargo_karva_binary() {
142+
let mut cmd = Command::new(binary);
143+
// Tell the karva process where to find the instrumented worker binary
144+
if let Some(worker) = cargo_worker_binary() {
145+
cmd.env("KARVA_WORKER_BINARY", worker.as_str());
146+
}
147+
// Pass VIRTUAL_ENV + coverage flag so the embedded Python in the
148+
// worker subprocess activates the venv (for pytest, karva package, etc.)
149+
cmd.env("VIRTUAL_ENV", self.venv_path.as_str());
150+
cmd.env(karva_static::EnvVars::KARVA_COVERAGE_INTERNAL, "1");
151+
cmd
152+
} else {
153+
Command::new(self.venv_binary("karva"))
154+
}
155+
}
156+
135157
pub fn command(&self) -> Command {
136-
let mut command = Command::new(self.venv_binary("karva"));
158+
let mut command = self.karva_command();
137159
command.arg("test").current_dir(self.root());
138160
command
139161
}
@@ -145,7 +167,7 @@ impl TestContext {
145167
}
146168

147169
pub fn snapshot(&self, subcommand: &str) -> Command {
148-
let mut command = Command::new(self.venv_binary("karva"));
170+
let mut command = self.karva_command();
149171
command
150172
.arg("snapshot")
151173
.arg(subcommand)
@@ -154,7 +176,7 @@ impl TestContext {
154176
}
155177

156178
pub fn cache(&self, subcommand: &str) -> Command {
157-
let mut command = Command::new(self.venv_binary("karva"));
179+
let mut command = self.karva_command();
158180
command
159181
.arg("cache")
160182
.arg(subcommand)
@@ -335,6 +357,47 @@ fn cleanup_old_shared_venvs(cache_dir: &Utf8Path, current_venv_name: &str) {
335357
}
336358
}
337359

360+
/// Returns the path to the cargo-built `karva` binary when coverage mode is enabled.
361+
///
362+
/// Coverage mode is activated by setting the internal coverage env var.
363+
/// In this mode, the cargo-built binaries (instrumented by `cargo llvm-cov`)
364+
/// are used instead of the venv-installed console scripts.
365+
fn cargo_karva_binary() -> Option<&'static str> {
366+
if std::env::var(karva_static::EnvVars::KARVA_COVERAGE_INTERNAL).is_ok() {
367+
option_env!("CARGO_BIN_EXE_karva")
368+
} else {
369+
None
370+
}
371+
}
372+
373+
/// Finds the `karva-worker` binary for coverage.
374+
///
375+
/// Checks the same directory as the `karva` binary first (coverage target dir),
376+
/// then falls back to the standard `target/debug/` directory.
377+
fn cargo_worker_binary() -> Option<Utf8PathBuf> {
378+
let karva = cargo_karva_binary()?;
379+
let name = if cfg!(windows) {
380+
"karva-worker.exe"
381+
} else {
382+
"karva-worker"
383+
};
384+
385+
// Check same directory as karva binary (e.g., target/llvm-cov-target/debug/)
386+
if let Some(dir) = Utf8Path::new(karva).parent() {
387+
let worker = dir.join(name);
388+
if worker.exists() {
389+
return Some(worker);
390+
}
391+
}
392+
393+
// Fall back to standard target/debug/ directory
394+
let workspace_root = Utf8Path::new(env!("CARGO_MANIFEST_DIR"))
395+
.parent()
396+
.and_then(|p| p.parent())?;
397+
let worker = workspace_root.join("target").join("debug").join(name);
398+
worker.exists().then_some(worker)
399+
}
400+
338401
fn create_and_populate_venv(
339402
venv_path: &Utf8PathBuf,
340403
python_version: &str,

crates/karva_runner/src/orchestration.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use karva_collector::{CollectedPackage, CollectionSettings};
1616
use karva_logging::time::format_duration;
1717
use karva_metadata::ProjectSettings;
1818
use karva_project::Project;
19+
use karva_static::EnvVars;
1920

2021
use crate::collection::ParallelCollector;
2122
use crate::partition::{Partition, partition_collected_tests};
@@ -355,8 +356,17 @@ const MIN_TESTS_PER_WORKER: usize = 5;
355356
const KARVA_WORKER_BINARY_NAME: &str = "karva-worker";
356357
const WORKER_POLL_INTERVAL: Duration = Duration::from_millis(10);
357358

358-
/// Find the `karva-worker` binary by checking PATH, the project venv, and the active venv.
359+
/// Find the `karva-worker` binary by checking the `KARVA_WORKER_BINARY` env var,
360+
/// then PATH, the project venv, and the active venv.
359361
fn find_karva_worker_binary(current_dir: &Utf8PathBuf) -> Result<Utf8PathBuf> {
362+
if let Ok(path) = std::env::var(EnvVars::KARVA_WORKER_BINARY) {
363+
let path = Utf8PathBuf::from(path);
364+
if path.exists() {
365+
tracing::debug!(path = %path, "Found binary from KARVA_WORKER_BINARY env var");
366+
return Ok(path);
367+
}
368+
}
369+
360370
which::which(KARVA_WORKER_BINARY_NAME)
361371
.ok()
362372
.and_then(|path| Utf8PathBuf::try_from(path).ok())

crates/karva_static/src/lib.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ impl EnvVars {
1515
/// When set to "1" or "true", snapshot assertions write directly to `.snap`
1616
/// instead of creating `.snap.new` pending files.
1717
pub const KARVA_SNAPSHOT_UPDATE: &'static str = "KARVA_SNAPSHOT_UPDATE";
18+
19+
/// Override the path to the `karva-worker` binary.
20+
/// Used for coverage instrumentation where the cargo-built binary
21+
/// should be used instead of the venv-installed console script.
22+
pub const KARVA_WORKER_BINARY: &'static str = "KARVA_WORKER_BINARY";
23+
24+
/// Private env var that activates venv support for the embedded Python
25+
/// interpreter. Set by `just coverage-full` so the cargo-built worker
26+
/// can find pytest, karva, etc. from the venv's site-packages.
27+
/// The double-underscore prefix signals this is an internal implementation detail.
28+
#[doc(hidden)]
29+
pub const KARVA_COVERAGE_INTERNAL: &'static str = "__KARVA_COVERAGE";
1830
}
1931

2032
pub fn max_parallelism() -> NonZeroUsize {

crates/karva_test_semantic/src/utils.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ where
150150
F: for<'py> FnOnce(Python<'py>) -> R,
151151
{
152152
attach(|py| {
153+
coverage::activate_venv_if_coverage(py);
153154
let null_file = redirect_python_output(py, show_python_output);
154155
let result = f(py);
155156
if let Ok(Some(null_file)) = null_file {
@@ -159,6 +160,93 @@ where
159160
})
160161
}
161162

163+
/// Coverage-only venv activation, gated behind `__KARVA_COVERAGE`.
164+
///
165+
/// When the cargo-built worker runs under `cargo llvm-cov`, `PyO3`'s embedded
166+
/// Python doesn't know about the venv. This module fixes that by:
167+
/// 1. Registering the embedded `karva._karva` module (prevents type mismatches
168+
/// with the wheel's `.so`)
169+
/// 2. Adding the venv's site-packages (for pytest, etc.)
170+
/// 3. Fixing `sys.executable` (so `Command(sys.executable)` runs Python, not karva)
171+
///
172+
/// All of this is a no-op unless `__KARVA_COVERAGE` is set.
173+
mod coverage {
174+
use camino::{Utf8Path, Utf8PathBuf};
175+
use pyo3::prelude::*;
176+
use pyo3::types::PyAnyMethods;
177+
178+
pub(super) fn activate_venv_if_coverage(py: Python<'_>) {
179+
if std::env::var(karva_static::EnvVars::KARVA_COVERAGE_INTERNAL).is_err() {
180+
return;
181+
}
182+
let Ok(venv_path) = std::env::var("VIRTUAL_ENV") else {
183+
return;
184+
};
185+
186+
register_embedded_module(py);
187+
add_site_packages(py, &venv_path);
188+
fix_sys_executable(py, &venv_path);
189+
}
190+
191+
fn register_embedded_module(py: Python<'_>) {
192+
let Ok(module) = PyModule::new(py, "_karva") else {
193+
return;
194+
};
195+
if crate::init_module(py, &module).is_err() {
196+
return;
197+
}
198+
let _ = module.setattr("karva_run", py.None());
199+
let _ = module.setattr("karva_worker_run", py.None());
200+
if let Ok(sys) = py.import("sys") {
201+
if let Ok(modules) = sys.getattr("modules") {
202+
let _ = modules.set_item("karva._karva", &module);
203+
}
204+
}
205+
}
206+
207+
fn add_site_packages(py: Python<'_>, venv_path: &str) {
208+
let Some(sp) = find_site_packages(venv_path) else {
209+
return;
210+
};
211+
if let Ok(site) = py.import("site") {
212+
let _ = site.call_method1("addsitedir", (sp.as_str(),));
213+
}
214+
}
215+
216+
fn fix_sys_executable(py: Python<'_>, venv_path: &str) {
217+
let venv = Utf8Path::new(venv_path);
218+
let python_bin = if cfg!(windows) {
219+
venv.join("Scripts").join("python.exe")
220+
} else {
221+
venv.join("bin").join("python")
222+
};
223+
if python_bin.exists() {
224+
if let Ok(sys) = py.import("sys") {
225+
let _ = sys.setattr("executable", python_bin.as_str());
226+
}
227+
}
228+
}
229+
230+
fn find_site_packages(venv_path: &str) -> Option<Utf8PathBuf> {
231+
let venv = Utf8Path::new(venv_path);
232+
if cfg!(windows) {
233+
let sp = venv.join("Lib").join("site-packages");
234+
return sp.exists().then_some(sp);
235+
}
236+
let lib_dir = venv.join("lib");
237+
for entry in std::fs::read_dir(&lib_dir).ok()?.flatten() {
238+
if entry.file_name().to_string_lossy().starts_with("python") {
239+
if let Ok(sp) = Utf8PathBuf::from_path_buf(entry.path().join("site-packages")) {
240+
if sp.exists() {
241+
return Some(sp);
242+
}
243+
}
244+
}
245+
}
246+
None
247+
}
248+
}
249+
162250
/// A simple wrapper around `Python::attach` that initializes the Python interpreter first.
163251
pub(crate) fn attach<F, R>(f: F) -> R
164252
where

justfile

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,30 @@ test *args:
88
else \
99
cargo test {{args}}; \
1010
fi
11+
12+
coverage *args:
13+
#!/usr/bin/env bash
14+
set -euo pipefail
15+
16+
# Find llvm-cov and llvm-profdata from PATH, rustup sysroot, or Homebrew
17+
find_llvm_tool() {
18+
local tool="$1"
19+
if command -v "$tool" > /dev/null 2>&1; then echo "$tool"; return; fi
20+
local sysroot_bin="$(rustc --print sysroot)/lib/rustlib/$(rustc -vV | grep host | awk '{print $2}')/bin/$tool"
21+
if [ -x "$sysroot_bin" ]; then echo "$sysroot_bin"; return; fi
22+
local brew="/opt/homebrew/opt/llvm/bin/$tool"
23+
if [ -x "$brew" ]; then echo "$brew"; return; fi
24+
echo "error: could not find $tool" >&2; exit 1
25+
}
26+
LLVM_COV=$(find_llvm_tool llvm-cov)
27+
LLVM_PROFDATA=$(find_llvm_tool llvm-profdata)
28+
export LLVM_COV LLVM_PROFDATA
29+
30+
rm -rf target/wheels
31+
maturin build
32+
RUSTFLAGS="-C instrument-coverage -C llvm-args=--instrprof-atomic-counter-update-all" cargo build --target-dir target/llvm-cov-target -p karva_worker
33+
__KARVA_COVERAGE=1 cargo llvm-cov nextest --no-report {{args}}
34+
"$LLVM_PROFDATA" merge -failure-mode=warn target/llvm-cov-target/*.profraw -o target/llvm-cov-target/merged.profdata
35+
"$LLVM_COV" report target/llvm-cov-target/debug/karva -object target/llvm-cov-target/debug/karva-worker -instr-profile=target/llvm-cov-target/merged.profdata -ignore-filename-regex='(\.cargo|rustc-|/rustlib/|\.claude/)'
36+
"$LLVM_COV" show target/llvm-cov-target/debug/karva -object target/llvm-cov-target/debug/karva-worker -instr-profile=target/llvm-cov-target/merged.profdata -ignore-filename-regex='(\.cargo|rustc-|/rustlib/|\.claude/)' --format=html -output-dir=target/coverage-html
37+
rm -f default_*.profraw

0 commit comments

Comments
 (0)