From 303cadfa6bdca021376694e83d689c6882771b35 Mon Sep 17 00:00:00 2001 From: Michal Rus Date: Tue, 2 Jun 2026 10:20:34 +0200 Subject: [PATCH] chore(coverage): count real test coverage across all CI tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this, we didn’t count: - `blockfrost-tests` run agains `blockfrost-platform` (+ Dolos) and `blockfrost-gateway`, - Hydra tests, - etc. So the percentage was quite low. We failed `tarpauling` only under 8%… --- .github/workflows/ci.yaml | 115 +++++-- .github/workflows/hydra-nightly.yml | 60 ++++ .gitignore | 9 +- crates/error_decoder/Cargo.toml | 3 - crates/error_decoder/src/external.rs | 6 +- crates/error_decoder/src/tests/random.rs | 2 - crates/error_decoder/src/tests/specific.rs | 15 +- crates/node/Cargo.toml | 3 - crates/testgen/Cargo.toml | 1 - nix/devshells.nix | 1 - nix/internal/unix.nix | 358 ++++++++++++++++++++- 11 files changed, 502 insertions(+), 71 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5c47a6c1..694e768d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -84,8 +84,8 @@ jobs: exit 1 fi - build_and_test: - name: Build and Test + build: + name: Build needs: [check_action_pins, static_checks] runs-on: ${{ matrix.os }} strategy: @@ -134,10 +134,6 @@ jobs: - name: Build run: cargo build --workspace --release --verbose - - name: Test - timeout-minutes: 10 - run: cargo test --workspace --lib --verbose - - name: Package binaries if: github.event_name == 'workflow_dispatch' && github.event.inputs.upload_artifacts == 'true' run: | @@ -151,10 +147,28 @@ jobs: name: ${{ runner.os }}-binary path: artifacts/ + unit_tests: + name: Unit Tests + runs-on: [self-hosted, Linux, X64, nixos] + needs: [build, check_flake_lock] + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Run unit tests with coverage + timeout-minutes: 10 + run: nix run -L .#internal.x86_64-linux.coverage-unit-tests + + - name: Upload coverage + uses: actions/upload-artifact@v5 + with: + name: coverage-unit + path: unit.lcov + platform_integration_tests: name: Platform Integration Tests runs-on: [self-hosted, Linux, X64, nixos] - needs: [check_flake_lock] + needs: [build, check_flake_lock] steps: - name: Checkout repository uses: actions/checkout@v5 @@ -164,12 +178,20 @@ jobs: env: CARDANO_NODE_SOCKET_PATH: /run/cardano-node/node_preview.socket BLOCKFROST_PREVIEW_PROJECT_ID: ${{ secrets.BLOCKFROST_PREVIEW_PROJECT_ID }} - run: nix develop .# --command cargo test --verbose -p blockfrost-platform-integration-tests --no-fail-fast + run: nix run -L .#internal.x86_64-linux.coverage-platform-integ-tests + + - name: Upload coverage + uses: actions/upload-artifact@v5 + with: + name: coverage-platform-integ + path: platform-integ.lcov blockfrost_integration_tests: - name: ${{ matrix.mode == 'ignore-check' && 'Ignored' || 'Tests' }} (${{ matrix.network }}) + name: >- + ${{ matrix.mode == 'ignore-check' && 'Ignored' + || 'Tests' }} (${{ matrix.network }}) runs-on: [self-hosted, Linux, X64, nixos] - needs: [check_flake_lock] + needs: [build, check_flake_lock] strategy: fail-fast: false matrix: @@ -202,34 +224,21 @@ jobs: SUBMIT_MNEMONIC: ${{ secrets[matrix.submit_mnemonic_secret] }} DOLOS_ENDPOINT: ${{ matrix.dolos_endpoint }} CARDANO_NODE_SOCKET_PATH: ${{ matrix.cardano_node_socket_path }} + LLVM_PROFILE_FILE: ${{ github.workspace }}/blockfrost-%p-%m.profraw run: | target=${{ matrix.mode == 'ignore-check' && 'blockfrost-ignore-check' || 'blockfrost-tests' }} nix run -L .#internal.x86_64-linux."${target}-${{ matrix.network }}" - coverage: - name: Coverage - runs-on: ubuntu-latest - needs: [check_action_pins] - steps: - - name: Checkout repository - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1 - - - name: Cache Cargo build - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - shared-key: coverage + - name: Convert profraw to LCOV + if: matrix.mode != 'ignore-check' + run: nix run -L .#internal.x86_64-linux.coverage-convert-blockfrost - - name: Install cargo-tarpaulin - uses: taiki-e/install-action@c070f87102a1c75b3183910f391c1cb887fe13c8 # v2.77.6 + - name: Upload coverage + if: matrix.mode != 'ignore-check' + uses: actions/upload-artifact@v5 with: - tool: cargo-tarpaulin - - - name: Check coverage - timeout-minutes: 10 - run: cargo tarpaulin --workspace --all --lib --features tarpaulin --fail-under 8 + name: coverage-blockfrost-${{ matrix.network }} + path: blockfrost.lcov oci_build: name: OCI Build (${{ matrix.arch }}) @@ -333,3 +342,45 @@ jobs: set -x docker buildx imagetools create "${args[@]}" "${amd}" "${arm}" + + coverage_report: + name: Coverage Report + runs-on: [self-hosted, Linux, X64, nixos] + if: always() + needs: + - unit_tests + - platform_integration_tests + - blockfrost_integration_tests + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 # needed for git diff against origin/main + + - name: Download coverage artifacts + uses: actions/download-artifact@v5 + with: + pattern: coverage-* + merge-multiple: true + + - name: Restore Hydra nightly coverage from cache + id: hydra-cache + uses: actions/cache/restore@v4 + with: + path: hydra-combined.lcov + key: hydra-coverage-impossible-exact-match + restore-keys: hydra-coverage- + + - name: Generate coverage report + run: | + args=(--threshold 11) + if [ -f hydra-combined.lcov ]; then + args+=(--filter-hydra hydra-combined.lcov --filter-base origin/main) + fi + nix run -L .#internal.x86_64-linux.coverage-report -- "${args[@]}" + + - name: Upload HTML report + uses: actions/upload-artifact@v5 + with: + name: coverage-report + path: coverage-html/ diff --git a/.github/workflows/hydra-nightly.yml b/.github/workflows/hydra-nightly.yml index 5fb56cdc..40b009be 100644 --- a/.github/workflows/hydra-nightly.yml +++ b/.github/workflows/hydra-nightly.yml @@ -42,9 +42,22 @@ jobs: CARDANO_NODE_SOCKET_PATH: /run/cardano-node/node_preview.socket BLOCKFROST_PROJECT_ID: ${{ secrets.BLOCKFROST_TESTS_PROJECT_ID }} SUBMIT_MNEMONIC: ${{ secrets.BLOCKFROST_TESTS_SUBMIT_MNEMONIC }} + LLVM_PROFILE_FILE: ${{ github.workspace }}/hydra-pg-%p-%m.profraw run: | nix run -L .#internal.x86_64-linux.hydra-platform-gateway-test + - name: Convert profraw to LCOV + if: always() + run: nix run -L .#internal.x86_64-linux.coverage-convert-hydra-pg + + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v5 + with: + name: coverage-hydra-platform-gateway + path: hydra-platform-gateway.lcov + if-no-files-found: ignore + hydra_bridge_gateway_tests: name: Hydra micropayments tests between Bridge↔Gateway runs-on: [self-hosted, Linux, X64, nixos] @@ -58,5 +71,52 @@ jobs: CARDANO_NODE_SOCKET_PATH: /run/cardano-node/node_preview.socket BLOCKFROST_PROJECT_ID: ${{ secrets.BLOCKFROST_TESTS_PROJECT_ID }} SUBMIT_MNEMONIC: ${{ secrets.BLOCKFROST_TESTS_SUBMIT_MNEMONIC }} + LLVM_PROFILE_FILE: ${{ github.workspace }}/hydra-bg-%p-%m.profraw run: | nix run -L .#internal.x86_64-linux.hydra-bridge-gateway-test + + - name: Convert profraw to LCOV + if: always() + run: nix run -L .#internal.x86_64-linux.coverage-convert-hydra-bg + + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v5 + with: + name: coverage-hydra-bridge-gateway + path: hydra-bridge-gateway.lcov + if-no-files-found: ignore + + hydra_coverage_cache: + name: Cache Hydra coverage for CI + runs-on: ubuntu-latest + if: always() + needs: + - hydra_platform_gateway_tests + - hydra_bridge_gateway_tests + steps: + - name: Download Hydra coverage artifacts + uses: actions/download-artifact@v5 + with: + pattern: coverage-hydra-* + merge-multiple: true + + - name: Install lcov + run: sudo apt-get install -y lcov + + - name: Merge Hydra LCOVs + run: | + args=() + for f in hydra-*.lcov; do + [ -f "$f" ] && args+=(-a "$f") + done + if [ ${#args[@]} -gt 0 ]; then + lcov "${args[@]}" -o hydra-combined.lcov + fi + + - name: Save to cache + if: hashFiles('hydra-combined.lcov') != '' + uses: actions/cache/save@v4 + with: + path: hydra-combined.lcov + key: hydra-coverage-${{ github.run_id }} diff --git a/.gitignore b/.gitignore index cad92250..37a8db38 100644 --- a/.gitignore +++ b/.gitignore @@ -52,8 +52,7 @@ result-* *.log -# Ignore coverage files -build_rs_cov.profraw -cobertura.xml -tarpaulin-report.html -coverage +# Coverage artifacts (LLVM source-based coverage) +*.profraw +*.profdata +coverage-html/ diff --git a/crates/error_decoder/Cargo.toml b/crates/error_decoder/Cargo.toml index 20e6cf1c..2644e807 100644 --- a/crates/error_decoder/Cargo.toml +++ b/crates/error_decoder/Cargo.toml @@ -12,9 +12,6 @@ hex.workspace = true serde_json.workspace = true tokio.workspace = true -[features] -tarpaulin = [] - [build-dependencies] bf-build-utils.workspace = true diff --git a/crates/error_decoder/src/external.rs b/crates/error_decoder/src/external.rs index 08662458..d7091da8 100644 --- a/crates/error_decoder/src/external.rs +++ b/crates/error_decoder/src/external.rs @@ -54,13 +54,13 @@ impl ExternalDecoder { } /// A single global [`ExternalDecoder`] that you can cheaply use in tests. - #[cfg(all(test, not(feature = "tarpaulin")))] + #[cfg(test)] pub fn instance() -> Self { GLOBAL_INSTANCE.clone() } } -#[cfg(all(test, not(feature = "tarpaulin")))] +#[cfg(test)] static GLOBAL_INSTANCE: std::sync::LazyLock = std::sync::LazyLock::new(|| ExternalDecoder::spawn().expect("Failed to spawn ExternalDecoder")); @@ -70,12 +70,10 @@ mod tests { // which already cross-validates the Rust (pallas-hardano) implementation against the // Haskell (testgen-hs) external decoder. This module only contains tests that are // unique to the external decoder itself (e.g. crash-recovery behaviour). - #[cfg(not(feature = "tarpaulin"))] use super::*; #[tokio::test] //#[tracing_test::traced_test] - #[cfg(not(feature = "tarpaulin"))] async fn test_sanity() { let decoder = ExternalDecoder::spawn().unwrap(); diff --git a/crates/error_decoder/src/tests/random.rs b/crates/error_decoder/src/tests/random.rs index c2b60926..5bf1b3b0 100644 --- a/crates/error_decoder/src/tests/random.rs +++ b/crates/error_decoder/src/tests/random.rs @@ -1,5 +1,3 @@ -#![cfg(not(feature = "tarpaulin"))] - use bf_testgen::tests::{CaseType, check_generated_cases}; use pallas_hardano::display::haskell_error::serialize_error; use pallas_network::miniprotocols::localtxsubmission::TxValidationError; diff --git a/crates/error_decoder/src/tests/specific.rs b/crates/error_decoder/src/tests/specific.rs index 952cf993..3ebe8898 100644 --- a/crates/error_decoder/src/tests/specific.rs +++ b/crates/error_decoder/src/tests/specific.rs @@ -23,10 +23,6 @@ use pallas_network::miniprotocols::localtxsubmission::TxValidationError; /// This function takes a CBOR-encoded `ApplyTxErr`, and verifies our /// deserializer against the Haskell one. Use it for specific cases. -/// -/// Under `tarpaulin`, the external Haskell decoder (`testgen-hs`) is not -/// available, so we only exercise the Rust decode + serialize path (which is -/// what we want coverage for anyway). #[cfg(test)] pub(crate) async fn verify_one(cbor: &str) { use pallas_hardano::display::haskell_error::serialize_error; @@ -36,7 +32,6 @@ pub(crate) async fn verify_one(cbor: &str) { let our_decoding = decode_error(&cbor); let our_json = serialize_error(our_decoding).expect("Failed to serialize error"); - #[cfg(not(feature = "tarpaulin"))] { use crate::external::ExternalDecoder; @@ -57,12 +52,6 @@ pub(crate) async fn verify_one(cbor: &str) { assert_json_eq!(reference_json, our_json); } - - // Under tarpaulin: just assert the Rust decoder didn't panic and produced valid JSON. - #[cfg(feature = "tarpaulin")] - { - let _ = our_json; - } } #[cfg(test)] fn decode_error(bytes: &[u8]) -> TxValidationError { @@ -72,7 +61,7 @@ fn decode_error(bytes: &[u8]) -> TxValidationError { decoder.decode().unwrap() } -#[cfg(all(test, not(feature = "tarpaulin")))] +#[cfg(test)] macro_rules! assert_json_eq { ($left:expr, $right:expr) => { if $left != $right { @@ -90,7 +79,7 @@ macro_rules! assert_json_eq { }; } -#[cfg(all(test, not(feature = "tarpaulin")))] +#[cfg(test)] pub(crate) use assert_json_eq; // export it #[tokio::test] diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index b28e4919..65d54628 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -24,8 +24,5 @@ chrono.workspace = true [build-dependencies] bf-build-utils.workspace = true -[features] -tarpaulin = [] - [lints] workspace = true diff --git a/crates/testgen/Cargo.toml b/crates/testgen/Cargo.toml index 7d7d4a16..4f3c06a3 100644 --- a/crates/testgen/Cargo.toml +++ b/crates/testgen/Cargo.toml @@ -15,5 +15,4 @@ num_cpus.workspace = true crossbeam.workspace = true [features] -tarpaulin = [] test-utils = [] diff --git a/nix/devshells.nix b/nix/devshells.nix index dbc96e15..d7715115 100644 --- a/nix/devshells.nix +++ b/nix/devshells.nix @@ -35,7 +35,6 @@ in { {package = internal.hydra-node;} {package = internal.dolos;} {package = pkgs.cargo-nextest;} - {package = pkgs.cargo-tarpaulin;} { name = "cargo"; package = internal.rustPackages.cargo; diff --git a/nix/internal/unix.nix b/nix/internal/unix.nix index 4accd823..bdadf2ac 100644 --- a/nix/internal/unix.nix +++ b/nix/internal/unix.nix @@ -18,7 +18,11 @@ assert builtins.elem targetSystem ["x86_64-linux" "aarch64-linux" "aarch64-darwi in extendForTarget rec { rustPackages = inputs.fenix.packages.${pkgs.stdenv.hostPlatform.system}.stable; - craneLib = (inputs.crane.mkLib pkgs).overrideToolchain rustPackages.toolchain; + rustToolchain = inputs.fenix.packages.${pkgs.stdenv.hostPlatform.system}.combine [ + rustPackages.toolchain + rustPackages.llvm-tools # needed for -Cinstrument-coverage + ]; + craneLib = (inputs.crane.mkLib pkgs).overrideToolchain rustToolchain; src = lib.cleanSourceWith { src = lib.cleanSource ../../.; @@ -61,6 +65,18 @@ in # For better caching: cargoArtifacts = craneLib.buildDepsOnly commonArgs; + # Coverage-instrumented builds: deps must also be built with -Cinstrument-coverage + # because LLVM embeds counters at compile time. + coverageArgs = + commonArgs + // { + RUSTFLAGS = + (commonArgs.RUSTFLAGS or "") + + " -Cinstrument-coverage"; + }; + + coverageCargoArtifacts = craneLib.buildDepsOnly coverageArgs; + workspaceCargoToml = builtins.fromTOML (builtins.readFile (builtins.path {path = src + "/Cargo.toml";})); platformCargoToml = builtins.fromTOML (builtins.readFile (builtins.path {path = src + "/crates/platform/Cargo.toml";})); gatewayCargoToml = builtins.fromTOML (builtins.readFile (builtins.path {path = src + "/crates/gateway/Cargo.toml";})); @@ -91,6 +107,27 @@ in }; }); + blockfrost-platform-coverage = craneLib.buildPackage (coverageArgs + // { + cargoArtifacts = coverageCargoArtifacts; + inherit GIT_REVISION; + pname = "${packageName.pname}-coverage"; + doCheck = false; + postInstall = '' + chmod -R +w $out + mv $out/bin $out/libexec + mkdir -p $out/bin + ( cd $out/bin && ln -s ../libexec/${packageName.pname} ./ ; ) + mkdir -p $out/libexec/hydra-node/ + ln -s ${hydra-node}/bin/hydra-node $out/libexec/hydra-node/ + $out/bin/${packageName.pname} --version + ''; + meta = { + mainProgram = packageName.pname; + description = "blockfrost-platform (coverage instrumented)"; + }; + }); + mk-blockfrost-gateway = {mockDb ? false}: craneLib.buildPackage (commonArgs // { @@ -117,6 +154,31 @@ in blockfrost-gateway--dev-mock-db = mk-blockfrost-gateway {mockDb = true;}; + mk-blockfrost-gateway-coverage = {mockDb ? false}: + craneLib.buildPackage (coverageArgs + // { + cargoArtifacts = coverageCargoArtifacts; + inherit GIT_REVISION; + pname = gatewayCargoToml.package.name + lib.optionalString mockDb "-dev-mock-db" + "-coverage"; + doCheck = false; + meta = { + mainProgram = gatewayCargoToml.package.name; + description = "Blockfrost Gateway (coverage)" + lib.optionalString mockDb " (dev mock DB build)"; + }; + postInstall = '' + mv $out/bin $out/libexec + mkdir -p $out/bin + ( cd $out/bin && ln -s ../libexec/${gatewayCargoToml.package.name} ./ ; ) + mkdir -p $out/libexec/hydra-node/ + ln -s ${hydra-node}/bin/hydra-node $out/libexec/hydra-node/ + $out/bin/${gatewayCargoToml.package.name} --version + ''; + cargoExtraArgs = "--package blockfrost-gateway" + lib.optionalString mockDb " --features dev_mock_db"; + } + // (builtins.listToAttrs hydraScriptsEnvVars)); + + blockfrost-gateway--dev-mock-db-coverage = mk-blockfrost-gateway-coverage {mockDb = true;}; + blockfrost-sdk-bridge = craneLib.buildPackage (commonArgs // { inherit cargoArtifacts GIT_REVISION; @@ -134,6 +196,24 @@ in cargoExtraArgs = "--package blockfrost-sdk-bridge"; }); + blockfrost-sdk-bridge-coverage = craneLib.buildPackage (coverageArgs + // { + cargoArtifacts = coverageCargoArtifacts; + inherit GIT_REVISION; + pname = "${sdkBridgeCargoToml.package.name}-coverage"; + doCheck = false; + meta.mainProgram = sdkBridgeCargoToml.package.name; + postInstall = '' + mv $out/bin $out/libexec + mkdir -p $out/bin + ( cd $out/bin && ln -s ../libexec/${sdkBridgeCargoToml.package.name} ./ ; ) + mkdir -p $out/libexec/hydra-node/ + ln -s ${hydra-node}/bin/hydra-node $out/libexec/hydra-node/ + $out/bin/${sdkBridgeCargoToml.package.name} --version + ''; + cargoExtraArgs = "--package blockfrost-sdk-bridge"; + }); + cargoChecks = let # `cargo-udeps` and `cargo-shear --expand` require the Nightly toolchain: nightlyToolchain = inputs.fenix.packages.${pkgs.stdenv.hostPlatform.system}.complete.toolchain; @@ -669,6 +749,8 @@ in ignorelistOnly ? false, }: let inherit (pkgs) nodePackages; + platformBin = blockfrost-platform-coverage; + gatewayBin = blockfrost-gateway--dev-mock-db-coverage; in pkgs.writeShellApplication { name = @@ -766,7 +848,7 @@ in nft_asset = 'unused' EOF - ${lib.getExe blockfrost-gateway--dev-mock-db} \ + ${lib.getExe gatewayBin} \ --config "$tmpdir/gateway.toml" \ & gateway_pid=$! @@ -774,7 +856,7 @@ in sleep 1 wait4x http "$gateway_url/stats" --expect-status-code 200 --timeout 60s --interval 1s - ${lib.getExe blockfrost-platform} \ + ${lib.getExe platformBin} \ --server-address 127.0.0.1 \ --server-port "$platform_port" \ --log-level info \ @@ -938,8 +1020,8 @@ in cardano-address (python3.withPackages (ps: with ps; [portpicker])) wait4x - blockfrost-platform - blockfrost-gateway--dev-mock-db + blockfrost-platform-coverage + blockfrost-gateway--dev-mock-db-coverage ]; runtimeEnv = rec { NETWORK = "preview"; @@ -991,8 +1073,8 @@ in cardano-address (python3.withPackages (ps: with ps; [portpicker])) wait4x - blockfrost-sdk-bridge - blockfrost-gateway--dev-mock-db + blockfrost-sdk-bridge-coverage + blockfrost-gateway--dev-mock-db-coverage ]; runtimeEnv = rec { NETWORK = "preview"; @@ -1008,6 +1090,268 @@ in text = builtins.readFile ./hydra-bridge-gateway-test.sh; }; + mkCoverageCargoTest = { + cargoTestArgs, + profrawPrefix, + outputName, + }: + pkgs.writeShellApplication { + name = "coverage-${outputName}"; + runtimeInputs = [ + rustToolchain + pkgs.gcc + pkgs.pkg-config + pkgs.jq + ]; + text = let + hydraExports = + lib.concatMapStrings + ({ + name, + value, + }: "export ${name}=${lib.escapeShellArg value}\n") + hydraScriptsEnvVars; + in + '' + export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig:${pkgs.postgresql}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" + export RUSTFLAGS="-Clink-arg=-fuse-ld=bfd -Cinstrument-coverage" + export TESTGEN_HS_PATH="${lib.getExe testgen-hs}" + export GIT_REVISION="${GIT_REVISION}" + '' + + hydraExports + + '' + export LLVM_PROFILE_FILE="$PWD/${profrawPrefix}-%p-%m.profraw" + + test_ec=0 + cargo test ${cargoTestArgs} || test_ec=$? + + shopt -s nullglob + profraw_files=(${profrawPrefix}-*.profraw) + shopt -u nullglob + + if [ ''${#profraw_files[@]} -gt 0 ]; then + llvm-profdata merge -sparse "''${profraw_files[@]}" -o ${profrawPrefix}.profdata + + mapfile -t bins < <(cargo test ${cargoTestArgs} --no-run --message-format=json 2>/dev/null \ + | jq -r 'select(.executable != null) | .executable') + obj_args=() + for bin in "''${bins[@]}"; do + obj_args+=("--object=$bin") + done + + llvm-cov export \ + --instr-profile=${profrawPrefix}.profdata \ + --format=lcov \ + "''${obj_args[@]}" \ + > ${outputName}.lcov + echo "Coverage written to ${outputName}.lcov" + else + echo "Warning: no profraw files found with prefix '${profrawPrefix}'" + fi + + exit "$test_ec" + ''; + }; + + coverage-unit-tests = mkCoverageCargoTest { + cargoTestArgs = "--workspace --lib --verbose"; + profrawPrefix = "unit"; + outputName = "unit"; + }; + + coverage-platform-integ-tests = mkCoverageCargoTest { + cargoTestArgs = "--verbose -p blockfrost-platform-integration-tests --no-fail-fast"; + profrawPrefix = "integ"; + outputName = "platform-integ"; + }; + + mkCoverageConvert = { + profrawGlob, + objects, + outputName, + }: + pkgs.writeShellApplication { + name = "coverage-convert-${outputName}"; + runtimeInputs = [rustToolchain]; + text = let + objArrayItems = + lib.concatMapStringsSep " " + (o: "\"--object=${o.drv}/libexec/${o.binName}\"") + objects; + in '' + shopt -s nullglob + profraw_files=(${profrawGlob}) + shopt -u nullglob + + if [ ''${#profraw_files[@]} -eq 0 ]; then + echo "Warning: no profraw files matching '${profrawGlob}' found" + exit 0 + fi + + obj_args=(${objArrayItems}) + + llvm-profdata merge -sparse "''${profraw_files[@]}" -o ${outputName}.profdata + llvm-cov export \ + --instr-profile=${outputName}.profdata \ + --format=lcov \ + "''${obj_args[@]}" \ + > ${outputName}.lcov + echo "Coverage written to ${outputName}.lcov" + ''; + }; + + coverage-convert-blockfrost = mkCoverageConvert { + profrawGlob = "blockfrost-*.profraw"; + objects = [ + { + drv = blockfrost-platform-coverage; + binName = "blockfrost-platform"; + } + { + drv = blockfrost-gateway--dev-mock-db-coverage; + binName = "blockfrost-gateway"; + } + ]; + outputName = "blockfrost"; + }; + + coverage-convert-hydra-pg = mkCoverageConvert { + profrawGlob = "hydra-pg-*.profraw"; + objects = [ + { + drv = blockfrost-platform-coverage; + binName = "blockfrost-platform"; + } + { + drv = blockfrost-gateway--dev-mock-db-coverage; + binName = "blockfrost-gateway"; + } + ]; + outputName = "hydra-platform-gateway"; + }; + + coverage-convert-hydra-bg = mkCoverageConvert { + profrawGlob = "hydra-bg-*.profraw"; + objects = [ + { + drv = blockfrost-sdk-bridge-coverage; + binName = "blockfrost-sdk-bridge"; + } + { + drv = blockfrost-gateway--dev-mock-db-coverage; + binName = "blockfrost-gateway"; + } + ]; + outputName = "hydra-bridge-gateway"; + }; + + coverage-report = pkgs.writeShellApplication { + name = "coverage-report"; + runtimeInputs = with pkgs; [ + lcov + git + bc + gawk + ]; + text = '' + threshold=0 + filter_hydra="" + filter_base="origin/main" + lcov_files=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --threshold) + threshold="$2"; shift 2 ;; + --filter-hydra) + filter_hydra="$2"; shift 2 ;; + --filter-base) + filter_base="$2"; shift 2 ;; + -*) + echo "Unknown option: $1" >&2; exit 1 ;; + *) + lcov_files+=("$1"); shift ;; + esac + done + + if [ ''${#lcov_files[@]} -eq 0 ]; then + shopt -s nullglob + lcov_files=(*.lcov) + shopt -u nullglob + fi + + if [ ''${#lcov_files[@]} -eq 0 ]; then + echo "Error: no LCOV files found" >&2 + exit 1 + fi + + echo "Merging ''${#lcov_files[@]} LCOV file(s):" + printf ' %s\n' "''${lcov_files[@]}" + + # Filter hydra LCOV if requested + if [[ -n "$filter_hydra" && -f "$filter_hydra" ]]; then + changed=$(git diff --name-only "$filter_base...HEAD" 2>/dev/null || true) + if [ -z "$changed" ]; then + echo "No files changed vs $filter_base — using full Hydra LCOV" + cp "$filter_hydra" hydra-filtered.lcov + else + remove_args=() + while IFS= read -r f; do + [[ -n "$f" ]] && remove_args+=("*/$f") + done <<< "$changed" + echo "Filtering ''${#remove_args[@]} modified file(s) from Hydra LCOV" + if [ ''${#remove_args[@]} -gt 0 ]; then + lcov --remove "$filter_hydra" "''${remove_args[@]}" \ + -o hydra-filtered.lcov 2>/dev/null || cp "$filter_hydra" hydra-filtered.lcov + else + cp "$filter_hydra" hydra-filtered.lcov + fi + fi + # Replace original hydra file with filtered version in the merge list + new_files=() + for f in "''${lcov_files[@]}"; do + [[ "$f" != "$filter_hydra" ]] && new_files+=("$f") + done + new_files+=("hydra-filtered.lcov") + lcov_files=("''${new_files[@]}") + fi + + merge_args=() + for f in "''${lcov_files[@]}"; do + [[ -f "$f" ]] && merge_args+=(-a "$f") + done + + if [ ''${#merge_args[@]} -eq 0 ]; then + echo "Error: no valid LCOV files to merge" >&2 + exit 1 + fi + + lcov "''${merge_args[@]}" -o combined.lcov + + # Keep only this workspace's source files (matches both $PWD and Nix build paths) + lcov --extract combined.lcov '*/crates/*' -o combined.lcov + lcov --summary combined.lcov + + if [ "$threshold" -gt 0 ]; then + pct=$(lcov --summary combined.lcov 2>&1 \ + | awk '/lines/ { sub(/%/, "", $2); print $2 }') + echo "Combined line coverage: $pct%" + if (( $(echo "$pct < $threshold" | bc -l) )); then + echo "Error: Coverage $pct% is below threshold $threshold%" >&2 + exit 1 + fi + fi + + genhtml combined.lcov \ + --output-directory coverage-html \ + --title "blockfrost-platform coverage" \ + --legend \ + --branch-coverage + + echo "HTML report written to coverage-html/" + ''; + }; + midnight = let fenix = inputs.fenix.packages.${pkgs.stdenv.hostPlatform.system};