diff --git a/.cargo/config.toml b/.cargo/config.toml index 9ea1c564c8139..722753f3016f1 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -31,3 +31,5 @@ rustflags = [ "-Clink-arg=-fuse-ld=lld", ] +[alias] +coverage = "run --bin coverage" diff --git a/.gitignore b/.gitignore index da9dcece2dff9..514d5d6ee3943 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ tests_output/ vendor/ /out/ coverage/ +!crates/coverage/ *.tsbuildinfo .eslintcache diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44332ed60aa97..419b9f8ef74ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,15 @@ Thank you for your interest in contributing to Turborepo! - [General dependencies](#general-dependencies) -- [Optional dependencies](#optional-dependencies) + - [Optional dependencies](#optional-dependencies) - [Structure of the repository](#structure-of-the-repository) - [Building Turborepo](#building-turborepo) - - [TLS implementation](#tls-implementation) + - [TLS Implementation](#tls-implementation) - [Running tests](#running-tests) - [Manually testing `turbo`](#manually-testing-turbo) - [Repositories to test with](#repositories-to-test-with) - [Debugging tips](#debugging-tips) + - [Links in error messages](#links-in-error-messages) - [Verbose logging](#verbose-logging) - [Crash logs](#crash-logs) - [Terminal UI debugging](#terminal-ui-debugging) @@ -17,7 +18,7 @@ Thank you for your interest in contributing to Turborepo! - [Contributing to an existing example](#contributing-to-an-existing-example) - [Philosophy for new examples](#philosophy-for-new-examples) - [Designing a new example](#designing-a-new-example) - - [Testing examples](#testing-examples) + - [Testing examples](#testing-examples) ## General dependencies @@ -36,6 +37,11 @@ You will need to have these dependencies installed on your machine to work on th - Linux: `sudo apt update && sudo apt install jq zstd` - Windows: `choco install jq zstandard` - On Linux, ensure LLD (LLVM Linker) is installed, as it's not installed by default on many Linux distributions (e.g. `apt install lld`). +- For coverage reporting, install the `llvm-tools-preview` component: + + ```bash + rustup component add llvm-tools-preview + ``` ## Structure of the repository @@ -78,6 +84,20 @@ Now, from the root directory, you can run: cargo test ``` +- Unit tests with coverage + +```bash +cargo coverage +``` + +After running coverage tests, you can manually open the HTML report by navigating to the `coverage/html/index.html` file in your browser, or use the `--open` flag to automatically open it: + +You can also add `--open` to your script to automatically open the file when coverage is completed. + +```bash +cargo coverage -- --open +``` + - A module's unit tests ```bash diff --git a/Cargo.lock b/Cargo.lock index 322dc0090605e..f6c3064fc43ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1414,6 +1414,19 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" +[[package]] +name = "coverage" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "glob", + "serde_json", + "tracing", + "tracing-subscriber", + "which 6.0.3", +] + [[package]] name = "cpp_demangle" version = "0.4.3" @@ -2229,6 +2242,12 @@ dependencies = [ "url", ] +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + [[package]] name = "globset" version = "0.4.14" @@ -2409,6 +2428,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "hostname" version = "0.3.1" @@ -4253,7 +4281,7 @@ dependencies = [ "regex", "syn 1.0.109", "tempfile", - "which", + "which 4.4.0", ] [[package]] @@ -6383,7 +6411,7 @@ dependencies = [ "tempfile", "turbopath", "turborepo-lib", - "which", + "which 4.4.0", ] [[package]] @@ -6793,7 +6821,7 @@ dependencies = [ "uds_windows", "wax", "webbrowser", - "which", + "which 4.4.0", ] [[package]] @@ -6928,7 +6956,7 @@ dependencies = [ "turborepo-lockfiles", "turborepo-unescape", "wax", - "which", + "which 4.4.0", ] [[package]] @@ -6952,7 +6980,7 @@ dependencies = [ "turborepo-ci", "turborepo-telemetry", "wax", - "which", + "which 4.4.0", ] [[package]] @@ -7024,7 +7052,7 @@ dependencies = [ "turbopath", "turborepo-ci", "turborepo-vt100", - "which", + "which 4.4.0", "windows-sys 0.59.0", ] @@ -7542,6 +7570,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.31", + "winsafe", +] + [[package]] name = "winapi" version = "0.3.9" @@ -7859,6 +7899,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/Cargo.toml b/Cargo.toml index 51bc929251264..4fa7853984d2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/tower-uds", "crates/turbo-trace", "crates/turborepo*", + "crates/coverage", "packages/turbo-repository/rust", ] diff --git a/crates/coverage/Cargo.toml b/crates/coverage/Cargo.toml new file mode 100644 index 0000000000000..1369816014de2 --- /dev/null +++ b/crates/coverage/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "coverage" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[[bin]] +name = "coverage" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0" +clap = { version = "4.0", features = ["derive"] } +glob = "0.3" +serde_json = "1.0" +tracing = "0.1" +tracing-subscriber = "0.3" +which = "6.0" diff --git a/crates/coverage/src/main.rs b/crates/coverage/src/main.rs new file mode 100644 index 0000000000000..9d4c5d2d22696 --- /dev/null +++ b/crates/coverage/src/main.rs @@ -0,0 +1,343 @@ +use std::process::Command; + +use anyhow::{Context, Result}; +use clap::Parser; +use tracing::{info, warn}; + +#[derive(Parser)] +#[command(name = "coverage")] +#[command(about = "Generate Rust code coverage reports")] +struct Args { + /// Open the HTML report in browser when finished + #[arg(long)] + open: bool, +} + +/// Find the llvm-profdata binary using rustup +fn find_llvm_profdata() -> Result { + // First try to find it in PATH + if let Ok(output) = Command::new("which").arg("llvm-profdata").output() { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path.is_empty() { + return Ok(std::path::PathBuf::from(path)); + } + } + } + + // Try to find it using rustup + let output = Command::new("rustup") + .args(["which", "llvm-profdata"]) + .output() + .context("Failed to run rustup which llvm-profdata")?; + + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path.is_empty() { + return Ok(std::path::PathBuf::from(path)); + } + } + + // Try to find it in the rustup toolchain directory + let rustup_home = std::env::var("RUSTUP_HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| { + let home = std::env::var("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| { + if cfg!(target_os = "windows") { + std::path::PathBuf::from(std::env::var("USERPROFILE").unwrap_or_default()) + } else { + std::path::PathBuf::from("/home") + } + }); + home.join(".rustup") + }); + + // Get the active toolchain + let toolchain_output = Command::new("rustup") + .args(["show", "active-toolchain"]) + .output() + .context("Failed to get active toolchain")?; + + let toolchain = if toolchain_output.status.success() { + String::from_utf8_lossy(&toolchain_output.stdout) + .split_whitespace() + .next() + .unwrap_or("stable") + .to_string() + } else { + "stable".to_string() + }; + + // Get the target triple + let target_output = Command::new("rustc") + .args(["-vV"]) + .output() + .context("Failed to get target triple")?; + + let target = if target_output.status.success() { + let output_str = String::from_utf8_lossy(&target_output.stdout); + output_str + .lines() + .find_map(|line| line.strip_prefix("host: ")) + .unwrap_or("unknown") + .to_string() + } else { + "unknown".to_string() + }; + + let llvm_profdata_path = rustup_home + .join("toolchains") + .join(toolchain) + .join("lib") + .join("rustlib") + .join(target) + .join("bin") + .join("llvm-profdata"); + + if llvm_profdata_path.exists() { + return Ok(llvm_profdata_path); + } + + anyhow::bail!( + "llvm-profdata not found. Install it with: rustup component add llvm-tools-preview" + ); +} + +/// Find the llvm-cov binary using the same approach as llvm-profdata +fn find_llvm_cov() -> Result { + // First try to find it in PATH + if let Ok(output) = Command::new("which").arg("llvm-cov").output() { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path.is_empty() { + return Ok(std::path::PathBuf::from(path)); + } + } + } + + // Try to find it using rustup + let output = Command::new("rustup") + .args(["which", "llvm-cov"]) + .output() + .context("Failed to run rustup which llvm-cov")?; + + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path.is_empty() { + return Ok(std::path::PathBuf::from(path)); + } + } + + // Use the same path as llvm-profdata but with llvm-cov + let llvm_profdata_path = find_llvm_profdata()?; + let llvm_cov_path = llvm_profdata_path.parent().unwrap().join("llvm-cov"); + + if llvm_cov_path.exists() { + return Ok(llvm_cov_path); + } + + anyhow::bail!("llvm-cov not found. Install it with: rustup component add llvm-tools-preview"); +} + +fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + let args = Args::parse(); + let project_root = std::env::current_dir()?; + let coverage_dir = project_root.join("coverage"); + + // Create coverage directory + std::fs::create_dir_all(&coverage_dir)?; + + // Find the LLVM tools + let llvm_profdata_path = find_llvm_profdata()?; + let llvm_cov_path = find_llvm_cov()?; + + info!("Using llvm-profdata: {}", llvm_profdata_path.display()); + info!("Using llvm-cov: {}", llvm_cov_path.display()); + + info!("Running tests with coverage instrumentation..."); + + // Run tests with coverage instrumentation + let test_status = Command::new("cargo") + .args(["test", "--tests", "--workspace"]) + .env("RUSTFLAGS", "-C instrument-coverage") + .env( + "LLVM_PROFILE_FILE", + coverage_dir.join("turbo-%m-%p.profraw"), + ) + .current_dir(&project_root) + .status() + .context("Failed to run cargo test")?; + + if !test_status.success() { + anyhow::bail!("Tests failed"); + } + + info!("Merging coverage data..."); + + // Merge coverage data + let profdata_path = coverage_dir.join("turbo.profdata"); + let profraw_files = glob::glob(&coverage_dir.join("*.profraw").to_string_lossy()) + .context("Failed to glob profraw files")?; + + let mut profraw_paths = Vec::new(); + for entry in profraw_files { + profraw_paths.push(entry?); + } + + if profraw_paths.is_empty() { + warn!("No profraw files found"); + return Ok(()); + } + + let mut merge_cmd = Command::new(&llvm_profdata_path); + merge_cmd + .args(["merge", "-sparse"]) + .args(&profraw_paths) + .args(["-o", profdata_path.to_string_lossy().as_ref()]); + + let merge_status = merge_cmd + .current_dir(&project_root) + .status() + .context("Failed to merge coverage data")?; + + if !merge_status.success() { + anyhow::bail!("Failed to merge coverage data"); + } + + // Get test binaries + info!("Finding test binaries..."); + let binaries_output = Command::new("cargo") + .args([ + "test", + "--tests", + "--no-run", + "--message-format=json", + "--workspace", + ]) + .env("RUSTFLAGS", "-C instrument-coverage") + .current_dir(&project_root) + .output() + .context("Failed to get test binaries")?; + + let binaries_json = + String::from_utf8(binaries_output.stdout).context("Failed to parse cargo output")?; + + let mut object_args = Vec::new(); + for line in binaries_json.lines() { + if let Ok(json) = serde_json::from_str::(line) { + if let Some(profile) = json.get("profile") { + if let Some(test) = profile.get("test") { + if test.as_bool() == Some(true) { + if let Some(filenames) = json.get("filenames") { + if let Some(filenames_array) = filenames.as_array() { + for filename in filenames_array { + if let Some(path) = filename.as_str() { + if !path.contains("dSYM") { + object_args.push(format!("--object={}", path)); + } + } + } + } + } + } + } + } + } + } + + // Generate summary report + info!("Generating coverage summary..."); + + let mut report_cmd = Command::new(&llvm_cov_path); + report_cmd + .args(["report"]) + .arg(format!("--instr-profile={}", profdata_path.display())) + .args([ + "--ignore-filename-regex=/.cargo/registry", + "--ignore-filename-regex=/.cargo/git", + "--ignore-filename-regex=/.rustup/toolchains", + "--ignore-filename-regex=/target/", + ]) + .args(&object_args); + + let report_output = report_cmd + .current_dir(&project_root) + .output() + .context("Failed to generate coverage report")?; + + if !report_output.status.success() { + anyhow::bail!("Failed to generate coverage report"); + } + + print!("{}", String::from_utf8_lossy(&report_output.stdout)); + + // Generate HTML report + info!("Generating HTML coverage report..."); + + let html_dir = coverage_dir.join("html"); + std::fs::create_dir_all(&html_dir)?; + + let mut show_cmd = Command::new(&llvm_cov_path); + show_cmd + .args(["show", "--format=html"]) + .arg(format!("--output-dir={}", html_dir.display())) + .arg(format!("--instr-profile={}", profdata_path.display())) + .args([ + "--ignore-filename-regex=/.cargo/registry", + "--ignore-filename-regex=/.cargo/git", + "--ignore-filename-regex=/.rustup/toolchains", + "--ignore-filename-regex=/target/", + ]) + .args(&object_args); + + let show_status = show_cmd + .current_dir(&project_root) + .status() + .context("Failed to generate HTML coverage report")?; + + if !show_status.success() { + anyhow::bail!("Failed to generate HTML coverage report"); + } + + info!( + "Coverage report generated at {}/html/index.html", + coverage_dir.display() + ); + + // Open HTML report if requested + if args.open { + let html_path = coverage_dir.join("html").join("index.html"); + if html_path.exists() { + info!("Opening HTML report in browser..."); + + // Use platform-appropriate command to open the browser + let open_command = if cfg!(target_os = "windows") { + "start" + } else if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; + + let open_status = Command::new(open_command) + .arg(html_path.to_string_lossy().as_ref()) + .status() + .context("Failed to open HTML report")?; + + if !open_status.success() { + warn!("Failed to open HTML report in browser"); + } + } else { + warn!( + "HTML report not found at expected location: {}", + html_path.display() + ); + } + } + + Ok(()) +}