From 227bee907d00c03f6d56eb5380c108379b948fa9 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Fri, 2 Oct 2026 13:39:55 +0200 Subject: [PATCH] test: add traceable pytest black-box coverage scenarios --- .github/workflows/tests.yml | 48 ++- MODULE.bazel | 1 + MODULE.bazel.lock | 2 + README.md | 9 +- docs/BUILD | 5 +- docs/verification/verification_report.rst | 70 ++-- integration_tests/.bazelrc | 4 + integration_tests/run_integration_test.sh | 361 +----------------- pyproject.toml | 2 +- tools/integration_tests/BUILD | 34 ++ tools/integration_tests/_blackbox_support.py | 209 ++++++++++ tools/integration_tests/conftest.py | 87 +++++ .../integration_tests/test_error_handling.py | 76 ++++ tools/integration_tests/test_gcov.py | 163 ++++++++ tools/integration_tests/test_llvm_gates.py | 98 +++++ tools/integration_tests/test_llvm_reports.py | 181 +++++++++ 16 files changed, 946 insertions(+), 404 deletions(-) create mode 100644 tools/integration_tests/BUILD create mode 100644 tools/integration_tests/_blackbox_support.py create mode 100644 tools/integration_tests/conftest.py create mode 100644 tools/integration_tests/test_error_handling.py create mode 100644 tools/integration_tests/test_gcov.py create mode 100644 tools/integration_tests/test_llvm_gates.py create mode 100644 tools/integration_tests/test_llvm_reports.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2741efe..e32f119 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,7 +45,7 @@ jobs: - name: Build everything run: bazel build --lockfile_mode=error //... - name: Run unit and Starlark analysis tests - run: bazel test --lockfile_mode=error //score_coverage/... //tools/... + run: bazel test --lockfile_mode=error --test_tag_filters=-integration //score_coverage/... //tools/... - name: Static analysis of the Python (ruff, pylint, ty; findings fail the build) run: bazel build --lockfile_mode=error --config=lint //score_coverage/... //tools/... - name: Measure structural coverage of the tool itself (coverage.py) @@ -67,7 +67,7 @@ jobs: echo "collected $(find tests-report -name test.xml | wc -l) test.xml files" - uses: actions/upload-artifact@v4 with: - name: tests-report + name: unit-tests-report path: tests-report if-no-files-found: error retention-days: 3 @@ -83,18 +83,54 @@ jobs: - uses: eclipse-score/cicd-actions/setup-bazel-cache@setup-bazel-cache/v0.1.0 with: disk-cache-key: integration_tests - - name: Run the end-to-end pipeline test (C++ + Rust consumer workspace) - run: integration_tests/run_integration_test.sh + - name: Run traceable pytest black-box scenarios + run: bazel test --lockfile_mode=error //tools/integration_tests:blackbox_test + - name: Collect pytest JUnit results for the documentation build + if: always() + run: | + mkdir -p tests-report/tools/integration_tests/blackbox_test + if [ -f bazel-testlogs/tools/integration_tests/blackbox_test/test.xml ]; then + cp bazel-testlogs/tools/integration_tests/blackbox_test/test.xml \ + tests-report/tools/integration_tests/blackbox_test/test.xml + fi + - name: Upload integration pytest results + if: always() + uses: actions/upload-artifact@v4 + with: + name: integration-tests-report + path: tests-report + if-no-files-found: ignore + retention-days: 3 - name: Upload coverage report of the integration workspace if: always() uses: actions/upload-artifact@v4 with: name: integration_coverage_report - path: integration_tests/coverage_artifact + path: bazel-testlogs/tools/integration_tests/blackbox_test/test.outputs/coverage_artifact if-no-files-found: ignore retention-days: 10 + test_reports: + needs: [unit_tests, integration_tests] + runs-on: ubuntu-24.04 + steps: + - name: Download unit test results + uses: actions/download-artifact@v4 + with: + name: unit-tests-report + path: tests-report + - name: Download integration test results + uses: actions/download-artifact@v4 + with: + name: integration-tests-report + path: tests-report + - uses: actions/upload-artifact@v4 + with: + name: tests-report + path: tests-report + if-no-files-found: error + retention-days: 3 docs: - needs: unit_tests + needs: test_reports uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@d2083d5dac0e309643d9f495e61342dfbaf07ed5 # main, 2026-09-24 permissions: contents: read diff --git a/MODULE.bazel b/MODULE.bazel index e1cfd46..00a2f6f 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -59,6 +59,7 @@ use_repo(pip, "pip_score_coverage") # Development-only dependencies (repository hygiene: copyright, formatting) ############################################################################### bazel_dep(name = "score_tooling", version = "2.2.0", dev_dependency = True) +bazel_dep(name = "score_tools", version = "0.0.3", dev_dependency = True) # use_format_targets() (from score_tooling) loads these from the ROOT module's # repo mapping, so the root has to declare them itself. diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 939ccf6..650c2d2 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -821,6 +821,8 @@ "https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/modules/score_toolchains_rust/0.10.0/source.json": "8bda773be264da16d2a82a03ebb737421dd4a35855f1e9a5d03d9722d84c1df5", "https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/modules/score_tooling/2.2.0/MODULE.bazel": "178ba4862246b6ba2bbcd96b7e9e728299b19fb94bcf23d315dc2299aabf7178", "https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/modules/score_tooling/2.2.0/source.json": "a76f2d093cff26d5b256ce531bb2f69b6c667c968f99a156327fd194d4f36e61", + "https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/modules/score_tools/0.0.3/MODULE.bazel": "69f441bf28d938de2b6aef61ec91e65633f8ea908d2527e6f5a424895c9ac388", + "https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/modules/score_tools/0.0.3/source.json": "84fda1c7e1e73811b67c239ce4d10800132c87dbc0af549ab425e66e679dc37f", "https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/modules/sphinxdocs/2.2.0/MODULE.bazel": "not found", "https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/modules/stardoc/0.5.0/MODULE.bazel": "not found", "https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/modules/stardoc/0.5.1/MODULE.bazel": "not found", diff --git a/README.md b/README.md index 18e8423..56c0c4d 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,9 @@ Exit codes: `0` gate passed, `1` gate failed, `2` no verdict possible. - `score_coverage/` — implementation (Python report tooling, Starlark rules) and unit tests, including Starlark analysis tests. - `integration_tests/` — a self-contained consumer workspace (C++ + Rust) - exercised end to end by `run_integration_test.sh` against a hand-derived - ground truth (`expected_lcov.dat`); also the reference for the adoption guide. + exercised by named pytest scenarios in `//tools/integration_tests:blackbox_test` + against hand-derived LLVM and gcov ground truths; also the reference for the + adoption guide. - `tools/` — repository hygiene (copyright, format, lint aspects) and the self-coverage gate. - `docs/` — the docs-as-code tree. @@ -68,10 +69,10 @@ Exit codes: `0` gate passed, `1` gate failed, `2` no verdict possible. ## Development ```bash -bazel test //score_coverage/... //tools/... # unit + analysis tests +bazel test --test_tag_filters=-integration //score_coverage/... //tools/... # unit + analysis tests +bazel test //tools/integration_tests:blackbox_test # consumer black-box scenarios bazel build --config=lint //score_coverage/... //tools/... # ruff, pylint, ty bazel coverage --combined_report=lcov //score_coverage/tests:all bazel run //tools:self_coverage_gate -- --min-lines 95 --min-branches 87 -integration_tests/run_integration_test.sh # end-to-end (downloads LLVM + Ferrocene) bazel run //tools:format.fix && bazel run //tools:copyright.check ``` diff --git a/docs/BUILD b/docs/BUILD index 55c6dd1..e43620e 100644 --- a/docs/BUILD +++ b/docs/BUILD @@ -23,5 +23,8 @@ docs( source_dir = ".", # Only these test.xml files become testcase needs (JUnit results under # bazel-testlogs/ or tests-report/ at the workspace root). - test_sources = ["score_coverage/tests"], + test_sources = [ + "score_coverage/tests", + "tools/integration_tests", + ], ) diff --git a/docs/verification/verification_report.rst b/docs/verification/verification_report.rst index 9b4caa1..2e58adb 100644 --- a/docs/verification/verification_report.rst +++ b/docs/verification/verification_report.rst @@ -80,39 +80,36 @@ Test inventory * - ``//score_coverage/tests/starlark:coverage_scope_tests`` (14 analysis tests) - 14 - scope_transitive, scope_excludes, scope_baseline_objects, scope_gcno - * - ``integration_tests/run_integration_test.sh`` (25 end-to-end checks) - - 25 - - validation_ground_truth, instrumentation_hint, report_baseline_zero, report_relative_paths, - report_allowlist, report_unmapped, gcov_merge, gcov_baseline, gcov_html, - gate_exit_codes, gate_no_verdict, just_unknown_id, artifacts, - summary_first + * - ``//tools/integration_tests:blackbox_test`` + - 31 + - validation_ground_truth, instrumentation_hint, report_baseline_zero, + report_relative_paths, report_allowlist, report_unmapped, gcov_merge, + gcov_baseline, gcov_html, gate_metric, gate_exit_codes, gate_no_verdict, + just_markers, just_unknown_id, artifacts, summary_first Requirement coverage -------------------- -The links from test cases to requirements are generated: every unit test class -carries ``@verifies()``, which writes ``PartiallyVerifies``, -``TestType`` and ``DerivationTechnique`` into the JUnit XML of the test run, and -docs-as-code turns the results into ``testcase`` needs with back-links on the -requirements (``testlink`` column below, with the execution result of each -case). The links reflect the test run that preceded the documentation build. +The links from test cases to requirements are generated: each pytest function +uses the ``score_pytest`` metadata decorator to write ``PartiallyVerifies``, +``TestType`` and ``DerivationTechnique`` into the JUnit XML. Docs-as-code turns +the results into ``testcase`` needs with back-links on the requirements +(``testlink`` column below, with the execution result of each case). The links +reflect the test run that preceded the documentation build. .. needtable:: Requirements and the tests that verify them :types: tool_req :columns: id;title;testlink :style: table -Four requirements are verified outside the pytest suites and therefore carry no -generated link: +Three requirements are verified outside the pytest suites and therefore carry +no generated link: - :need:`tool_req__coverage_scope_transitive`, :need:`tool_req__coverage_scope_excludes` and - :need:`tool_req__coverage_scope_baseline_objects` are verified by the eleven + :need:`tool_req__coverage_scope_baseline_objects` are verified by the fourteen Starlark analysis tests in ``score_coverage/tests/starlark`` (rules_testing produces no test properties). -- :need:`tool_req__coverage_validation_ground_truth` is verified by the - end-to-end run ``integration_tests/run_integration_test.sh`` (golden LCOV - comparison, see below). .. needpie:: Test results of the linked test cases :labels: passed, failed, skipped @@ -169,21 +166,24 @@ yamlfmt the workflows; copyright headers are checked on every file. End-to-end validation --------------------- -``integration_tests/run_integration_test.sh`` builds a consumer workspace with a -tested and an untested C++ library, a header-only library reached through -``strip_include_prefix``, a tested Rust library and an untested Rust binary, one -justified line, and asserts: - -1. the gate fails at 100 % and passes at 10 % (effective and raw mode); -2. the HTML, the summary and the archive tree are produced, the summary also - when the gate fails; -3. the untested C++ file and the untested Rust binary appear with ``LH:0``; -4. the LCOV matches ``expected_lcov.dat``, a hand-derived ground truth, record - by record; -5. the justified line raises effective above raw coverage; -6. fault injection: a corrupt report and a non-numeric threshold exit 2, and a - misspelt justification id is reported and does not raise the effective - coverage. +``//tools/integration_tests:blackbox_test`` runs named pytest cases against a +copied consumer workspace with a tested and an untested C++ library, a +header-only library reached through ``strip_include_prefix``, a tested Rust +library and an untested Rust binary. Separate LLVM and gcov fixtures collect +their reports before cases exercise: + +1. effective and raw gate thresholds, with parametrized pass and fail values; +2. Markdown summaries, archive contents, working HTML and stylesheet links, + canonical source paths, and exclusion of forwarded external code; +3. unmapped-file categories, exact zero-count baselines, and hand-derived LLVM + and gcov LCOV ground truths; +4. the gcov warning and zero-count fallback when a narrow instrumentation + filter omits a tested library; +5. fault injection for corrupt reports, invalid thresholds and unknown + justification markers. + +The pytest JUnit XML records a test case and requirement metadata for every +scenario, including each parametrized gate value. Deviations ---------- @@ -191,10 +191,10 @@ Deviations - Structural coverage of the Python is below 100 %. The remaining lines are error-handling and llvm-cov fallback paths in ``reporter.py`` and ``effective_coverage.py``; they are covered by the fault-injection checks of - the integration test where they are reachable and will be closed or justified + the black-box test where they are reachable and will be closed or justified before the first qualified release. - Starlark (``coverage_scope.bzl``, ``reporter_wrapper.bzl``) has no structural - coverage tooling. The rule and aspect are verified by eight analysis tests + coverage tooling. The rule and aspect are verified by fourteen analysis tests and by the end-to-end run. - The gcovr backend of ``effective_coverage.py`` is unit-tested against real gcovr 8.6 markup but is not reachable through ``generate_coverage_html`` in diff --git a/integration_tests/.bazelrc b/integration_tests/.bazelrc index 8dcf9f8..b3f19ae 100644 --- a/integration_tests/.bazelrc +++ b/integration_tests/.bazelrc @@ -107,3 +107,7 @@ coverage:gcov --test_lang_filters=cc # the tests too so header-only code that only a test translation unit # instantiates is measured (the scope allowlist still drops the test sources). coverage:gcov --instrument_test_targets + +# Used by the black-box scenario that verifies Bazel drops gcov counters for +# the cross-package library when the consumer filter omits //lib/. +coverage:gcov_narrow_filter --instrumentation_filter=^//lib/test[/:],^//src[/:] diff --git a/integration_tests/run_integration_test.sh b/integration_tests/run_integration_test.sh index 5cd3d2e..cd3d939 100755 --- a/integration_tests/run_integration_test.sh +++ b/integration_tests/run_integration_test.sh @@ -11,362 +11,9 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -# End-to-end test of the score_coverage LLVM coverage pipeline, run against -# this consumer-style workspace. Asserts the properties the pipeline -# guarantees: -# 1. Untested in-scope files (C++ AND Rust) appear at exact 0% in the LCOV. -# 2. The effective-coverage gate fails at threshold 100 and passes at a low -# threshold. -# 3. The justified line raises effective coverage above raw coverage. +# Keep the former integration-test command as a shortcut to the traceable +# pytest target. set -euo pipefail -cd "$(dirname "$0")" - -# In GitHub Actions GITHUB_STEP_SUMMARY is set for THIS job; unset it so the -# many generate_coverage_html invocations below don't each append to the real -# run page. The dedicated summary test sets its own target file. -unset GITHUB_STEP_SUMMARY || true - -echo "=== Running coverage build ===" -bazel coverage --config=llvm_cov //... --build_tests_only - -YAML="tools/coverage/coverage_justifications.yaml" - -echo "=== Gate must FAIL at threshold 100 (uncovered fixtures exist) ===" -if COVERAGE_THRESHOLD=100 bazel run @score_coverage//:generate_coverage_html -- \ - --yaml "${YAML}" --archive coverage_artifacts; then - echo "ERROR: coverage gate passed at threshold 100 despite uncovered files" >&2 - exit 1 -fi -echo "OK: gate failed as expected" - -echo "=== Gate must PASS at a low threshold ===" -COVERAGE_THRESHOLD=10 bazel run @score_coverage//:generate_coverage_html -- \ - --yaml "${YAML}" -echo "OK: gate passed as expected" - -echo "=== Without --yaml: HTML still produced, gate applies to RAW coverage ===" -if COVERAGE_THRESHOLD=100 bazel run @score_coverage//:generate_coverage_html; then - echo "ERROR: raw-coverage gate passed at threshold 100" >&2 - exit 1 -fi -COVERAGE_THRESHOLD=10 bazel run @score_coverage//:generate_coverage_html -if [[ ! -f coverage_linux/index.html ]]; then - echo "ERROR: HTML report missing after no-yaml run" >&2 - exit 1 -fi -echo "OK: no-yaml mode works (HTML produced, raw gate enforced)" - -# The following sections all run, in order. Each one deletes summary.md -# before its own generate_coverage_html invocation so a stale file from the -# previous section cannot produce a false pass — in particular, the -# failing-gate section must prove the file was RE-created by THAT run. -echo "=== --summary-md must produce a markdown job summary ===" -rm -f summary.md -COVERAGE_THRESHOLD=10 bazel run @score_coverage//:generate_coverage_html -- \ - --yaml "${YAML}" --summary-md summary.md -for marker in "## Coverage summary" "| Lines |" "Raw vs effective" \ - "Coverage by directory" "Files at exact 0% (2)" \ - "| In-scope files without coverage data | 1 |" \ - "In-scope files without coverage data (1)" '- `src/unused_api.h`' \ - "Declaration-only headers (3)" "Compiled sources without code of their own (1)" '- `src/empty_unit.cpp`'; do - if ! grep -qF -- "${marker}" summary.md; then - echo "ERROR: '${marker}' missing from summary.md" >&2 - exit 1 - fi -done -grep -q "█" summary.md || { echo "ERROR: progress bars missing from summary.md" >&2; exit 1; } -echo "OK: --summary-md works" - -echo "=== Summary must still be written when the gate FAILS ===" -rm -f summary.md -if COVERAGE_THRESHOLD=100 bazel run @score_coverage//:generate_coverage_html -- \ - --yaml "${YAML}" --summary-md summary.md; then - echo "ERROR: gate unexpectedly passed at threshold 100" >&2 - exit 1 -fi -[[ -s summary.md ]] || { echo "ERROR: summary.md missing after failing gate" >&2; exit 1; } -echo "OK: summary survives a failing gate" - -echo "=== GITHUB_STEP_SUMMARY convenience default (no flag) ===" -rm -f step_summary.md -printf '# existing content\n' > step_summary.md -GITHUB_STEP_SUMMARY="$(pwd)/step_summary.md" COVERAGE_THRESHOLD=10 \ - bazel run @score_coverage//:generate_coverage_html -- --yaml "${YAML}" -grep -qF "# existing content" step_summary.md || { echo "ERROR: append mode overwrote the step summary" >&2; exit 1; } -grep -qF "## Coverage summary" step_summary.md || { echo "ERROR: summary not appended to GITHUB_STEP_SUMMARY" >&2; exit 1; } -rm -f summary.md step_summary.md -echo "OK: GITHUB_STEP_SUMMARY convenience works" - -echo "=== --archive-dir must produce an unzipped artifacts tree ===" -COVERAGE_THRESHOLD=10 bazel run @score_coverage//:generate_coverage_html -- \ - --yaml "${YAML}" --archive-dir artifacts_dir -for f in artifacts_dir/coverage_linux/index.html artifacts_dir/coverage_report.dat \ - artifacts_dir/justification_report/summary.txt; do - if [[ ! -f "$f" ]]; then - echo "ERROR: ${f} missing from --archive-dir output" >&2 - exit 1 - fi -done -# unused_api.h is the finding; coverable.h / uncovered.h hold declarations for -# compiled .cpp files and empty_unit.cpp is a compiled placeholder: categorised. -EXPECTED_UNMAPPED=$'compiled-without-code\tsrc/empty_unit.cpp\ndeclaration-only\tlib/cross_pkg.h\ndeclaration-only\tsrc/coverable.h\ndeclaration-only\tsrc/uncovered.h\nno-data\tsrc/unused_api.h' -if [[ "$(cat artifacts_dir/unmapped_files.txt)" != "${EXPECTED_UNMAPPED}" ]]; then - echo "ERROR: unmapped_files.txt unexpected:" >&2 - cat artifacts_dir/unmapped_files.txt >&2 - exit 1 -fi -echo "OK: in-scope files without coverage data are listed and categorised in the archive" -rm -rf artifacts_dir -echo "OK: --archive-dir works" - -echo "=== Untested files must appear at exact 0% in the LCOV ===" -unzip -p coverage_artifacts.zip artifacts/coverage_report.dat > lcov.dat - -check_zero_coverage() { - local file="$1" - if ! grep -q "SF:.*${file}" lcov.dat; then - echo "ERROR: ${file} missing from LCOV (baseline mechanism broken)" >&2 - exit 1 - fi - # The record for the file must report zero lines hit. - if ! awk -v f="${file}" ' - $0 ~ "^SF:" && $0 ~ f {rec=1} - rec && /^LH:/ {print $0; exit ($0 == "LH:0") ? 0 : 1} - rec && /^end_of_record/ {exit 1}' lcov.dat; then - echo "ERROR: ${file} is present but not at 0% coverage" >&2 - exit 1 - fi - echo "OK: ${file} present at 0%" -} - -check_zero_coverage "src/uncovered.cpp" -check_zero_coverage "rust/main.rs" - -echo "=== LCOV must match the hand-verified ground truth exactly ===" -# Normalise: drop function records, keep one record per file sorted by SF, so -# the comparison is independent of record order and of symbol names. -normalise_lcov() { - grep -v '^FN' "$1" | awk ' - /^SF:/ { key = $0; rec = "" } - { rec = rec $0 "\n" } - /^end_of_record/ { records[key] = rec } - END { n = asorti(records, keys); for (i = 1; i <= n; i++) printf "%s", records[keys[i]] }' -} -normalise_lcov lcov.dat > actual_normalised.dat -grep -v '^#' expected_lcov.dat | normalise_lcov /dev/stdin > expected_normalised.dat -if ! diff -u expected_normalised.dat actual_normalised.dat; then - echo "ERROR: coverage data differs from expected_lcov.dat (see diff above)" >&2 - exit 1 -fi -rm -f actual_normalised.dat expected_normalised.dat -echo "OK: LCOV matches the ground truth" - -echo "=== A library tested from a test/ subpackage must be measured (explicit --instrumentation_filter) ===" -# Bazel guesses the filter from the packages of the test targets; //lib is -# outside that guess and would be compiled without instrumentation. The -# config sets the filter explicitly; the golden above holds the numbers. -grep -q "^SF:lib/cross_pkg.cpp$" lcov.dat || { echo "ERROR: lib/cross_pkg.cpp missing: --instrumentation_filter not applied" >&2; exit 1; } -echo "OK: cross-package library measured on the LLVM backend" - -echo "=== Every index link must point at an existing page; no machine or config paths ===" -rm -rf link_check && mkdir link_check -unzip -q coverage_artifacts.zip -d link_check -HTML_DIR="link_check/artifacts/coverage_linux" -[[ -f "${HTML_DIR}/index.html" ]] || { echo "ERROR: ${HTML_DIR}/index.html missing" >&2; exit 1; } -LINKS="$(grep -oE "href='coverage/[^']+\.html'" "${HTML_DIR}/index.html" | sed -E "s/^href='//; s/'$//")" -[[ -n "${LINKS}" ]] || { echo "ERROR: no source links in index.html" >&2; exit 1; } -while IFS= read -r link; do - if [[ ! -f "${HTML_DIR}/${link}" ]]; then - echo "ERROR: index.html links to ${link}, which was not generated" >&2 - exit 1 - fi - case "${link}" in - coverage/bazel-out/*|coverage/home/*|coverage/tmp/*|*/_virtual_includes/*) - echo "ERROR: index.html link is not a canonical workspace path: ${link}" >&2 - exit 1 ;; - esac - # The page's stylesheet link must resolve from the page's location. - page_dir="$(dirname "${HTML_DIR}/${link}")" - css="$(grep -oE "href='(\.\./)*style\.css'" "${HTML_DIR}/${link}" | head -1 | sed -E "s/^href='//; s/'$//")" - if [[ -z "${css}" || ! -f "${page_dir}/${css}" ]]; then - echo "ERROR: ${link}: stylesheet link '${css}' does not resolve" >&2 - exit 1 - fi -done <<< "${LINKS}" -for page in "coverage/src/vendored/include/vendored/inline_math.h.html" \ - "coverage/external/itest_external+/include/vext/vext.h.html"; do - grep -qF "href='${page}'" "${HTML_DIR}/index.html" || { echo "ERROR: ${page} not linked from index.html" >&2; exit 1; } -done -if grep -q "itest_external+/extlib" "${HTML_DIR}/index.html"; then - echo "ERROR: forwarded third-party library leaked into the HTML report" >&2 - exit 1 -fi -if grep -q "extlib" lcov.dat; then - echo "ERROR: forwarded third-party library leaked into the LCOV" >&2 - exit 1 -fi -rm -rf link_check -echo "OK: $(echo "${LINKS}" | wc -l) index links resolve, canonical paths only, third-party code excluded" - -echo "=== A header compiled only through a test-only twin target must be attributed to the declared file ===" -# Without the fallback the data sits under _virtual_includes/vendored_math_internal/ -# (a target outside the scope), gets excluded, and the header is listed as no-data. -grep -q "^SF:src/vendored/include/vendored/inline_math.h$" lcov.dat || { echo "ERROR: inline_math.h not attributed to its declared path" >&2; exit 1; } -if grep -q "vendored_math_internal" lcov.dat artifacts_dir/unmapped_files.txt 2>/dev/null; then - echo "ERROR: the test-only twin's virtual path leaked into the report" >&2; exit 1 -fi -echo "OK" - -echo "=== A header nothing includes must be reported as unmapped, not invented in the LCOV ===" -if grep -q "unused_api" lcov.dat; then - echo "ERROR: src/unused_api.h has no compiled code and must not have an LCOV record" >&2 - exit 1 -fi -echo "OK" - -echo "=== Covered files must be present with hits ===" -grep -q "SF:.*src/coverable.cpp" lcov.dat || { echo "ERROR: coverable.cpp missing" >&2; exit 1; } -grep -q "SF:.*rust/lib.rs" lcov.dat || { echo "ERROR: lib.rs missing" >&2; exit 1; } -echo "OK" - -echo "=== Justified line must raise effective coverage above raw ===" -SUMMARY="$(unzip -p coverage_artifacts.zip artifacts/justification_report/summary.txt)" -echo "${SUMMARY}" -JUSTIFIED="$(echo "${SUMMARY}" | grep -oP 'Justified lines:\s+\K[0-9]+')" -if [[ "${JUSTIFIED}" -lt 1 ]]; then - echo "ERROR: expected at least one justified line, got ${JUSTIFIED}" >&2 - exit 1 -fi -RAW="$(echo "${SUMMARY}" | grep -oP 'Raw line coverage:\s+\K[0-9.]+')" -EFFECTIVE="$(echo "${SUMMARY}" | grep -oP 'Effective line coverage:\s+\K[0-9.]+')" -if ! awk "BEGIN {exit (${EFFECTIVE} > ${RAW}) ? 0 : 1}"; then - echo "ERROR: effective coverage ${EFFECTIVE}% not above raw ${RAW}%" >&2 - exit 1 -fi -echo "OK: effective ${EFFECTIVE}% > raw ${RAW}%" - -echo "=== Fault injection: a broken report must yield NO verdict (exit 2), never a pass ===" -REPORT="bazel-out/_coverage/_coverage_report.dat" -cp "${REPORT}" report.backup -chmod u+w "${REPORT}" -printf 'this is not a zip archive' > "${REPORT}" -set +e -COVERAGE_THRESHOLD=0 bazel run @score_coverage//:generate_coverage_html > /dev/null 2>&1 -rc=$? -set -e -cp report.backup "${REPORT}" -rm -f report.backup -if [[ "${rc}" -ne 2 ]]; then - echo "ERROR: corrupt report gave exit code ${rc}, expected 2" >&2 - exit 1 -fi -echo "OK: corrupt report is rejected with exit 2" - -echo "=== Fault injection: a non-numeric threshold must be rejected (exit 2) ===" -set +e -COVERAGE_THRESHOLD=lenient bazel run @score_coverage//:generate_coverage_html > /dev/null 2>&1 -rc=$? -set -e -if [[ "${rc}" -ne 2 ]]; then - echo "ERROR: bad threshold gave exit code ${rc}, expected 2" >&2 - exit 1 -fi -echo "OK: invalid threshold is rejected with exit 2" - -echo "=== Fault injection: an unknown justification id must not count as covered ===" -sed -i 's/itest-positive-branch/itest-typo-branch/' src/coverable.cpp -set +e -COVERAGE_THRESHOLD=10 bazel run @score_coverage//:generate_coverage_html -- --yaml "${YAML}" --archive-dir typo_dir > typo.log 2>&1 -rc=$? -set -e -sed -i 's/itest-typo-branch/itest-positive-branch/' src/coverable.cpp -if [[ "${rc}" -ne 0 ]]; then - cat typo.log - echo "ERROR: run with an unknown marker id failed unexpectedly (${rc})" >&2 - exit 1 -fi -grep -q "references unknown ID 'itest-typo-branch'" typo.log || { echo "ERROR: unknown marker id was not reported" >&2; exit 1; } -TYPO_EFFECTIVE="$(grep -oP 'Effective line coverage:\s+\K[0-9.]+' typo_dir/justification_report/summary.txt)" -TYPO_RAW="$(grep -oP 'Raw line coverage:\s+\K[0-9.]+' typo_dir/justification_report/summary.txt)" -if [[ "${TYPO_EFFECTIVE}" != "${TYPO_RAW}" ]]; then - echo "ERROR: unknown marker id still raised effective (${TYPO_EFFECTIVE}) above raw (${TYPO_RAW})" >&2 - exit 1 -fi -rm -rf typo_dir typo.log -echo "OK: unknown justification id is reported and does not count" - -# --------------------------------------------------------------------------- -# gcov backend: GCC toolchain, Bazel's own per-test collector, score_coverage's -# gcov reporter. The same path a QNX (QCC) on-target run takes; only the QEMU -# transport of score_qnx_unit_tests differs. -# --------------------------------------------------------------------------- -echo "=== gcov backend: coverage build with the GCC toolchain ===" -bazel coverage --config=gcov //src/... //lib/... --build_tests_only - -echo "=== gcov backend: gate, HTML, archive ===" -rm -rf gcov_artifacts_dir -if COVERAGE_THRESHOLD=100 bazel run @score_coverage//:generate_coverage_html -- \ - --yaml "${YAML}" --archive-dir gcov_artifacts_dir --summary-md gcov_summary.md coverage_gcov > gcov_run.log 2>&1; then - cat gcov_run.log - echo "ERROR: gcov gate passed at threshold 100 despite uncovered files" >&2 - exit 1 -fi -grep -q "Effective coverage" gcov_run.log || { cat gcov_run.log; echo "ERROR: gcov run did not reach the gate" >&2; exit 1; } -COVERAGE_THRESHOLD=10 bazel run @score_coverage//:generate_coverage_html -- \ - --yaml "${YAML}" --archive-dir gcov_artifacts_dir --summary-md gcov_summary.md coverage_gcov -echo "OK: gcov gate fails at 100 and passes at 10" - -echo "=== gcov backend: LCOV must match the hand-verified ground truth ===" -normalise_lcov gcov_artifacts_dir/coverage_report.dat > actual_gcov.dat -grep -v '^#' expected_lcov_gcov.dat | normalise_lcov /dev/stdin > expected_gcov.dat -if ! diff -u expected_gcov.dat actual_gcov.dat; then - echo "ERROR: gcov coverage data differs from expected_lcov_gcov.dat (see diff above)" >&2 - exit 1 -fi -rm -f actual_gcov.dat expected_gcov.dat -echo "OK: gcov LCOV matches the ground truth" - -echo "=== gcov backend: every index link opens; justification applied; categories ===" -GHTML="gcov_artifacts_dir/coverage_gcov" -[[ -f "${GHTML}/index.html" ]] || { echo "ERROR: gcovr index.html missing" >&2; exit 1; } -GLINKS="$(grep -oE 'href="index\.[^"]+\.html"' "${GHTML}/index.html" | sed -E 's/^href="//; s/"$//' | sort -u)" -[[ -n "${GLINKS}" ]] || { echo "ERROR: no per-file links in the gcovr index" >&2; exit 1; } -while IFS= read -r link; do - [[ -f "${GHTML}/${link}" ]] || { echo "ERROR: gcovr index links to ${link}, which does not exist" >&2; exit 1; } -done <<< "${GLINKS}" -for page in coverable.cpp uncovered.cpp inline_math.h cross_pkg.cpp; do - ls "${GHTML}"/index."${page}".*.html > /dev/null 2>&1 || { echo "ERROR: no gcovr page for ${page}" >&2; exit 1; } -done -G_RAW="$(grep -oP 'Raw line coverage:\s+\K[0-9.]+' gcov_artifacts_dir/justification_report/summary.txt)" -G_EFF="$(grep -oP 'Effective line coverage:\s+\K[0-9.]+' gcov_artifacts_dir/justification_report/summary.txt)" -if ! awk "BEGIN {exit (${G_EFF} > ${G_RAW}) ? 0 : 1}"; then - echo "ERROR: gcov effective coverage ${G_EFF}% not above raw ${G_RAW}% (justification not applied on gcovr HTML)" >&2 - exit 1 -fi -EXPECTED_GCOV_UNMAPPED=$'compiled-without-code\tsrc/empty_unit.cpp\ndeclaration-only\tlib/cross_pkg.h\ndeclaration-only\tsrc/coverable.h\ndeclaration-only\tsrc/uncovered.h\nno-data\tsrc/unused_api.h\nnot-instrumented\trust/lib.rs\nnot-instrumented\trust/main.rs' -if [[ "$(cat gcov_artifacts_dir/unmapped_files.txt)" != "${EXPECTED_GCOV_UNMAPPED}" ]]; then - echo "ERROR: gcov unmapped_files.txt unexpected:" >&2 - cat gcov_artifacts_dir/unmapped_files.txt >&2 - exit 1 -fi -grep -qF "Not instrumentable by this backend (2)" gcov_summary.md || { echo "ERROR: not-instrumented section missing from the gcov summary" >&2; exit 1; } -rm -rf gcov_artifacts_dir gcov_summary.md gcov_run.log coverage_gcov -echo "OK: gcov HTML complete (${GLINKS//$'\n'/, }), effective ${G_EFF}% > raw ${G_RAW}%, categories as expected" - -echo "=== gcov backend: without the explicit filter the reporter must point at --instrumentation_filter ===" -# Bazel's guessed filter for these targets is ^//lib/test[/:],^//src[/:]; pass -# it explicitly to reproduce a consumer config that forgot the flag. Bazel's -# collector then drops lib/cross_pkg.cpp's counters; the file falls back to -# the 0 % baseline and the reporter must name the cause. -bazel coverage --config=gcov '--instrumentation_filter=^//lib/test[/:],^//src[/:]' //src/... //lib/... --build_tests_only > gcov_narrow.log 2>&1 || { cat gcov_narrow.log; exit 1; } -grep -q "WARNING: 1 in-scope files have no test data although their directory is tested from a test/ or tests/ subdirectory" gcov_narrow.log \ - || { cat gcov_narrow.log; echo "ERROR: the reporter did not warn about the narrow --instrumentation_filter" >&2; exit 1; } -grep -q -- "--instrumentation_filter=\^//\[/:\]" gcov_narrow.log || { echo "ERROR: the warning does not name the flag to set" >&2; exit 1; } -unzip -p bazel-out/_coverage/_coverage_report.dat lcov_report/lcov.dat | awk '/^SF:lib\/cross_pkg.cpp$/{p=1} p&&/^LH:/{print; exit}' | grep -q "^LH:0$" \ - || { echo "ERROR: expected lib/cross_pkg.cpp at 0 % under the narrow filter" >&2; exit 1; } -rm -f gcov_narrow.log -echo "OK: narrow --instrumentation_filter is detected and reported" - -echo "" -echo "=== All integration checks passed ===" +cd "$(dirname "$0")/.." +exec bazel test --lockfile_mode=error //tools/integration_tests:blackbox_test diff --git a/pyproject.toml b/pyproject.toml index b684a7d..7e7ad1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ [tool.ruff] line-length = 120 target-version = "py312" # the only interpreter the module supports (see MODULE.bazel) -extend-exclude = ["__pycache__", ".*", "bazel-*", "integration_tests"] +extend-exclude = ["__pycache__", ".*", "bazel-*", "/integration_tests"] [tool.ruff.lint] # Rule set of score_tooling's python_basics/pyproject.toml (S-CORE Python guideline). diff --git a/tools/integration_tests/BUILD b/tools/integration_tests/BUILD new file mode 100644 index 0000000..bc0d23c --- /dev/null +++ b/tools/integration_tests/BUILD @@ -0,0 +1,34 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("@score_tools//score_pytest:pytest.bzl", "score_pytest") + +score_pytest( + name = "blackbox_test", + srcs = [ + "test_error_handling.py", + "test_gcov.py", + "test_llvm_gates.py", + "test_llvm_reports.py", + ], + # score_pytest passes srcs to pytest as collection paths. Keep shared + # fixtures and helpers in runfiles without presenting them as test modules. + data = [ + "_blackbox_support.py", + "conftest.py", + ], + imports = ["."], + size = "large", + tags = ["integration", "local"], + timeout = "long", +) diff --git a/tools/integration_tests/_blackbox_support.py b/tools/integration_tests/_blackbox_support.py new file mode 100644 index 0000000..9ade8d6 --- /dev/null +++ b/tools/integration_tests/_blackbox_support.py @@ -0,0 +1,209 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Shared helpers for black-box coverage scenarios.""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import NamedTuple + +from attribute_plugin import Decorator, DerivationTechnique, add_test_properties + +_COVERAGE_REPORT = Path("bazel-out/_coverage/_coverage_report.dat") +JUSTIFICATIONS = "tools/coverage/coverage_justifications.yaml" +EXPECTED_UNMAPPED_LLVM = ( + "compiled-without-code\tsrc/empty_unit.cpp\n" + "declaration-only\tlib/cross_pkg.h\n" + "declaration-only\tsrc/coverable.h\n" + "declaration-only\tsrc/uncovered.h\n" + "no-data\tsrc/unused_api.h\n" +) +EXPECTED_UNMAPPED_GCOV = EXPECTED_UNMAPPED_LLVM + "not-instrumented\trust/lib.rs\nnot-instrumented\trust/main.rs\n" + + +@dataclass(frozen=True) +class CoverageReport: + """A consumer workspace and a saved report produced by one backend setup.""" + + workspace: Path + backend: str + archive: Path + collection_output: str + + def install(self) -> Path: + """Put this scenario's collected report at the documented Bazel path.""" + target = self.workspace / _COVERAGE_REPORT + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists(): + target.chmod(target.stat().st_mode | 0o200) + shutil.copyfile(self.archive, target) + target.chmod(target.stat().st_mode | 0o200) + return target + + +def run_bazel( + workspace: Path, + args: list[str], + *, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + """Run a consumer-facing Bazel command and capture its user-visible output.""" + command = shutil.which("bazel") + assert command, "Bazel must be available on PATH to run the integration scenarios" + process_env = os.environ.copy() + process_env.pop("GITHUB_STEP_SUMMARY", None) + if env: + process_env.update(env) + return subprocess.run( + [command, *args], + cwd=workspace, + env=process_env, + capture_output=True, + check=False, + text=True, + ) + + +def assert_exit_code(result: subprocess.CompletedProcess[str], expected: int) -> None: + assert result.returncode == expected, ( + f"command: {' '.join(result.args)}\n" + f"expected exit code {expected}, got {result.returncode}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + + +def verifies(*requirements: str, derivation: DerivationTechnique = "requirements-analysis") -> Decorator: + """Attach score_pytest requirement metadata to an interface scenario.""" + return add_test_properties( + partially_verifies=list(requirements), + test_type="interface-test", + derivation_technique=derivation, + ) + + +def fault_injection(*requirements: str, derivation: DerivationTechnique = "error-guessing") -> Decorator: + """Attach score_pytest requirement metadata to a fault-injection scenario.""" + return add_test_properties( + partially_verifies=list(requirements), + test_type="fault-injection", + derivation_technique=derivation, + ) + + +def collect_report( + workspace: Path, + backend: str, + targets: list[str], + destination: Path, + extra_args: list[str] | None = None, +) -> CoverageReport: + """Collect one backend report for a consumer-workspace scenario.""" + command = ["coverage", f"--config={backend}"] + if extra_args: + command.extend(extra_args) + command.extend(targets) + command.append("--build_tests_only") + result = run_bazel(workspace, command) + assert_exit_code(result, 0) + collected = workspace / _COVERAGE_REPORT + assert collected.is_file(), f"Bazel did not produce the expected {backend} coverage archive" + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(collected, destination) + return CoverageReport(workspace, backend, destination, result.stdout + result.stderr) + + +def generate_report( + report: CoverageReport, + *args: str, + threshold: str = "10", + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + """Run the public HTML-generation command against a saved backend report.""" + report.install() + command = ["run", "@score_coverage//:generate_coverage_html", "--", *args] + process_env = {"COVERAGE_THRESHOLD": threshold} + if env: + process_env.update(env) + return run_bazel(report.workspace, command, env=process_env) + + +def lcov_records(text: str) -> dict[str, list[str]]: + """Split LCOV data into source records, dropping function metadata.""" + records: dict[str, list[str]] = {} + current: list[str] = [] + source = "" + for line in text.splitlines(): + if line.startswith("#"): + continue + if line.startswith("SF:"): + source = line[3:] + if line.startswith("FN"): + continue + current.append(line) + if line == "end_of_record": + records[source] = current + current = [] + source = "" + return records + + +def normalise_lcov(text: str) -> str: + """Ignore function metadata and record order when comparing the goldens.""" + records = lcov_records(text) + return "".join("\n".join(records[source]) + "\n" for source in sorted(records)) + + +def lcov_from_artifacts(path: Path) -> str: + """Read LCOV data from either a generated archive directory or ZIP file.""" + if path.is_dir(): + return (path / "coverage_report.dat").read_text(encoding="utf-8") + with zipfile.ZipFile(path) as archive: + member = next( + name + for name in archive.namelist() + if name.endswith("lcov_report/lcov.dat") or name.endswith("coverage_report.dat") + ) + return archive.read(member).decode("utf-8") + + +class LineCounts(NamedTuple): + """Covered and found line totals from one LCOV source record.""" + + hits: int + found: int + + +def line_counts(records: dict[str, list[str]], source: str) -> LineCounts: + """Return a source record's covered-line and total-line counts.""" + record = records[source] + lines_found = next(int(line[3:]) for line in record if line.startswith("LF:")) + lines_hit = next(int(line[3:]) for line in record if line.startswith("LH:")) + return LineCounts(hits=lines_hit, found=lines_found) + + +def summary_metric(summary: str, name: str) -> float: + """Read a named percentage from the human-readable justification summary.""" + match = re.search(rf"{re.escape(name)}:\s+([0-9.]+)%", summary) + assert match, f"{name} missing from report summary:\n{summary}" + return float(match.group(1)) + + +def index_links(index: Path) -> list[str]: + """Return coverage-page links in an HTML index page.""" + return re.findall(r"href=['\"](coverage/[^'\"]+\.html)['\"]", index.read_text(encoding="utf-8")) diff --git a/tools/integration_tests/conftest.py b/tools/integration_tests/conftest.py new file mode 100644 index 0000000..673dd7c --- /dev/null +++ b/tools/integration_tests/conftest.py @@ -0,0 +1,87 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Shared consumer workspaces and backend reports for black-box scenarios. + +The consumer copy is created once per pytest run. LLVM and gcov reports are +also collected once each, then individual scenarios pass those saved reports +to the public report-generation command with their own options. +""" + +import shutil +from pathlib import Path + +import pytest +from _blackbox_support import CoverageReport, collect_report + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +_CONSUMER_WORKSPACE_DIRECTORY = "integration_tests" + + +def _ignore_generated_workspace_files(_directory: str, names: list[str]) -> set[str]: + """Keep the consumer copy limited to source and test fixtures.""" + generated = { + ".git", + ".ub_cache", + ".venv", + ".vscode", + ".pytest_cache", + ".ruff_cache", + ".integration-test-venv", + "__pycache__", + "external", + "_build", + "coverage_artifact", + "coverage_artifacts.zip", + "coverage_gcov", + "coverage_linux", + "gcov_artifacts_dir", + "gcov_run.log", + "gcov_summary.md", + "lcov.dat", + "link_check", + "test-reports", + "tests-report", + "typo_dir", + "user.bazelrc", + } + return {name for name in names if name in generated or name.startswith("bazel-")} + + +@pytest.fixture(scope="session") +def consumer_workspace(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Copy sources so nested Bazel outputs and source fault injection stay isolated.""" + root = tmp_path_factory.mktemp("score-coverage-blackbox") / "repo" + shutil.copytree(_REPOSITORY_ROOT, root, ignore=_ignore_generated_workspace_files) + return root / _CONSUMER_WORKSPACE_DIRECTORY + + +@pytest.fixture(scope="session") +def llvm_report(consumer_workspace: Path, tmp_path_factory: pytest.TempPathFactory) -> CoverageReport: + """Collect LLVM coverage for every target, including C++ and Rust tests.""" + return collect_report( + consumer_workspace, + "llvm_cov", + ["//..."], + tmp_path_factory.mktemp("llvm-report") / "coverage_report.dat", + ) + + +@pytest.fixture(scope="session") +def gcov_report(consumer_workspace: Path, tmp_path_factory: pytest.TempPathFactory) -> CoverageReport: + """Collect gcov for //src/... and //lib/...; LLVM covers Rust separately.""" + return collect_report( + consumer_workspace, + "gcov", + ["//src/...", "//lib/..."], + tmp_path_factory.mktemp("gcov-report") / "coverage_report.dat", + ) diff --git a/tools/integration_tests/test_error_handling.py b/tools/integration_tests/test_error_handling.py new file mode 100644 index 0000000..1fba8a8 --- /dev/null +++ b/tools/integration_tests/test_error_handling.py @@ -0,0 +1,76 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Black-box fault-injection scenarios for malformed coverage inputs.""" + +from pathlib import Path + +from _blackbox_support import ( + JUSTIFICATIONS, + CoverageReport, + assert_exit_code, + fault_injection, + generate_report, + run_bazel, + summary_metric, +) + + +@fault_injection("tool_req__coverage_gate_no_verdict") +def test_corrupt_llvm_report_returns_no_verdict(llvm_report: CoverageReport) -> None: + """A corrupt coverage archive returns exit 2 even with a permissive threshold.""" + report_path = llvm_report.install() + report_path.write_text("this is not a zip archive", encoding="utf-8") + try: + result = run_bazel( + llvm_report.workspace, + ["run", "@score_coverage//:generate_coverage_html"], + env={"COVERAGE_THRESHOLD": "0"}, + ) + finally: + # Restore the shared session report so later scenarios see valid input. + llvm_report.install() + assert_exit_code(result, 2) + + +@fault_injection("tool_req__coverage_gate_no_verdict", derivation="boundary-values") +def test_non_numeric_threshold_returns_no_verdict(llvm_report: CoverageReport) -> None: + """An invalid threshold is rejected with exit 2 instead of passing the gate.""" + result = generate_report(llvm_report, threshold="lenient") + assert_exit_code(result, 2) + + +@fault_injection("tool_req__coverage_just_unknown_id") +def test_unknown_justification_marker_is_reported_and_not_credited(llvm_report: CoverageReport, tmp_path: Path) -> None: + """An unrecognized code marker is reported and leaves effective equal to raw.""" + source = llvm_report.workspace / "src/coverable.cpp" + original = source.read_text(encoding="utf-8") + valid_marker = "COV_JUSTIFIED itest-positive-branch" + unknown_marker = "COV_JUSTIFIED itest-typo-branch" + assert original.count(valid_marker) == 1, "fault injection expects one known marker in coverable.cpp" + source.write_text(original.replace(valid_marker, unknown_marker), encoding="utf-8") + output = tmp_path / "typo-artifacts" + try: + result = generate_report( + llvm_report, + "--yaml", + JUSTIFICATIONS, + "--archive-dir", + str(output), + ) + finally: + # The consumer workspace is shared by the other LLVM scenarios. + source.write_text(original, encoding="utf-8") + assert_exit_code(result, 0) + assert "references unknown ID 'itest-typo-branch'" in result.stdout + result.stderr + summary = (output / "justification_report" / "summary.txt").read_text(encoding="utf-8") + assert summary_metric(summary, "Effective line coverage") == summary_metric(summary, "Raw line coverage") diff --git a/tools/integration_tests/test_gcov.py b/tools/integration_tests/test_gcov.py new file mode 100644 index 0000000..1f9c3ed --- /dev/null +++ b/tools/integration_tests/test_gcov.py @@ -0,0 +1,163 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Black-box scenarios for the gcov coverage backend.""" + +import re +import zipfile +from pathlib import Path + +import pytest +from _blackbox_support import ( + EXPECTED_UNMAPPED_GCOV, + JUSTIFICATIONS, + CoverageReport, + assert_exit_code, + collect_report, + generate_report, + lcov_from_artifacts, + lcov_records, + line_counts, + normalise_lcov, + summary_metric, + verifies, +) + + +@pytest.fixture(name="gcov_lcov_archive", scope="module") +def create_gcov_lcov_archive(gcov_report: CoverageReport, tmp_path_factory: pytest.TempPathFactory) -> Path: + """Generate the gcov LCOV archive used by the golden-report scenarios.""" + archive = tmp_path_factory.mktemp("gcov-lcov") / "coverage-artifacts" + result = generate_report(gcov_report, "--yaml", JUSTIFICATIONS, "--archive-dir", str(archive)) + assert_exit_code(result, 0) + return archive + + +@pytest.fixture(name="gcov_html_archive", scope="module") +def create_gcov_html_archive(gcov_report: CoverageReport, tmp_path_factory: pytest.TempPathFactory) -> Path: + """Generate gcov HTML and its Markdown summary for the archive scenarios.""" + output_root = tmp_path_factory.mktemp("gcov-html") + archive = output_root / "coverage-artifacts" + markdown_summary = output_root / "gcov-summary.md" + result = generate_report( + gcov_report, + "--yaml", + JUSTIFICATIONS, + "--archive-dir", + str(archive), + "--summary-md", + str(markdown_summary), + "coverage_gcov", + ) + assert_exit_code(result, 0) + return archive + + +@pytest.mark.parametrize( + ("threshold", "expected_exit"), + [("100", 1), ("10", 0)], + ids=["fails-at-100-percent", "passes-at-10-percent"], +) +@verifies("tool_req__coverage_gate_exit_codes", derivation="boundary-values") +def test_gcov_effective_gate_uses_reviewed_justifications( + gcov_report: CoverageReport, threshold: str, expected_exit: int +) -> None: + """Reviewed justifications make the gcov 10% gate pass and 100% gate fail.""" + result = generate_report(gcov_report, "--yaml", JUSTIFICATIONS, threshold=threshold) + assert_exit_code(result, expected_exit) + + +@verifies("tool_req__coverage_validation_ground_truth") +def test_gcov_lcov_matches_hand_derived_golden(gcov_report: CoverageReport, gcov_lcov_archive: Path) -> None: + """The generated gcov LCOV report matches its hand-derived golden.""" + actual = normalise_lcov(lcov_from_artifacts(gcov_lcov_archive)) + golden_path = gcov_report.workspace / "expected_lcov_gcov.dat" + expected = normalise_lcov(golden_path.read_text(encoding="utf-8")) + + assert actual == expected + + +@verifies("tool_req__coverage_gcov_merge", "tool_req__coverage_gcov_baseline") +def test_gcov_lcov_measures_vendored_header(gcov_lcov_archive: Path) -> None: + """The gcov LCOV report includes hits from the vendored C++ header.""" + records = lcov_records(lcov_from_artifacts(gcov_lcov_archive)) + header = "external/itest_external+/include/vext/vext.h" + + assert line_counts(records, header).hits > 0 + + +@verifies("tool_req__coverage_gcov_merge") +def test_gcov_lcov_omits_rust_sources(gcov_lcov_archive: Path) -> None: + """The gcov LCOV report contains no Rust source records.""" + records = lcov_records(lcov_from_artifacts(gcov_lcov_archive)) + assert not any(path.endswith(".rs") for path in records) + + +@verifies("tool_req__coverage_gcov_html") +def test_gcov_html_links_resolve_to_generated_pages(gcov_html_archive: Path) -> None: + """Every linked gcov HTML page exists in the consumer archive.""" + html_root = gcov_html_archive / "coverage_gcov" + index = (html_root / "index.html").read_text(encoding="utf-8") + page_links = re.findall(r'href="(index\.[^"]+\.html)"', index) + assert page_links + + for link in page_links: + assert (html_root / link).is_file(), f"gcov report page does not exist: {link}" + + +@verifies("tool_req__coverage_just_markers") +def test_gcov_summary_credits_reviewed_justifications(gcov_html_archive: Path) -> None: + """The gcov justification summary reports higher effective than raw coverage.""" + summary_path = gcov_html_archive / "justification_report" / "summary.txt" + summary = summary_path.read_text(encoding="utf-8") + raw_coverage = summary_metric(summary, "Raw line coverage") + effective_coverage = summary_metric(summary, "Effective line coverage") + + assert effective_coverage > raw_coverage + + +@verifies("tool_req__coverage_report_unmapped") +def test_gcov_archive_lists_unmapped_files(gcov_html_archive: Path) -> None: + """The gcov archive categorizes every source omitted from its report.""" + unmapped = (gcov_html_archive / "unmapped_files.txt").read_text(encoding="utf-8") + assert unmapped == EXPECTED_UNMAPPED_GCOV + + +@verifies("tool_req__coverage_report_unmapped") +def test_gcov_markdown_summary_explains_uninstrumented_rust(gcov_html_archive: Path) -> None: + """The Markdown summary explains that gcov cannot instrument the Rust files.""" + markdown_path = gcov_html_archive.parent / "gcov-summary.md" + markdown_summary = markdown_path.read_text(encoding="utf-8") + assert "Not instrumentable by this backend (2)" in markdown_summary + + +@verifies("tool_req__coverage_instrumentation_hint", derivation="error-guessing") +def test_gcov_narrow_filter_warns_and_leaves_excluded_library_at_zero(consumer_workspace: Path, tmp_path: Path) -> None: + """A narrowed instrumentation filter warns and leaves the omitted library at zero hits.""" + # This config builds both packages but instruments only //src/ and //lib/test/. + report = collect_report( + consumer_workspace, + "gcov", + ["//src/...", "//lib/..."], + tmp_path / "gcov-narrow-filter.dat", + extra_args=["--config=gcov_narrow_filter"], + ) + output = report.collection_output + + assert "in-scope files have no test data" in output + assert "WARNING: 1 in-scope files have no test data" in output + assert "--instrumentation_filter=^//[/:]" in output + + with zipfile.ZipFile(report.archive) as archive: + lcov_member = next(name for name in archive.namelist() if name.endswith("lcov_report/lcov.dat")) + records = lcov_records(archive.read(lcov_member).decode("utf-8")) + assert line_counts(records, "lib/cross_pkg.cpp").hits == 0 diff --git a/tools/integration_tests/test_llvm_gates.py b/tools/integration_tests/test_llvm_gates.py new file mode 100644 index 0000000..d13a194 --- /dev/null +++ b/tools/integration_tests/test_llvm_gates.py @@ -0,0 +1,98 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Black-box scenarios for LLVM coverage gate and summary behavior.""" + +from pathlib import Path + +import pytest +from _blackbox_support import ( + JUSTIFICATIONS, + CoverageReport, + assert_exit_code, + generate_report, + verifies, +) + + +@pytest.mark.parametrize( + ("threshold", "expected_exit"), + [("100", 1), ("10", 0)], + ids=["fails-at-100-percent", "passes-at-10-percent"], +) +@verifies("tool_req__coverage_gate_exit_codes", derivation="boundary-values") +def test_llvm_effective_gate_uses_reviewed_justifications( + llvm_report: CoverageReport, threshold: str, expected_exit: int +) -> None: + """Reviewed justifications make the 10% gate pass and the 100% gate fail.""" + result = generate_report(llvm_report, "--yaml", JUSTIFICATIONS, threshold=threshold) + assert_exit_code(result, expected_exit) + + +@pytest.mark.parametrize( + ("threshold", "expected_exit"), + [("100", 1), ("10", 0)], + ids=["raw-fails-at-100-percent", "raw-passes-at-10-percent"], +) +@verifies("tool_req__coverage_gate_metric", "tool_req__coverage_gate_exit_codes") +def test_llvm_without_yaml_gates_on_raw_coverage( + llvm_report: CoverageReport, threshold: str, expected_exit: int +) -> None: + """Without a justification file, raw coverage determines both gate outcomes.""" + result = generate_report(llvm_report, threshold=threshold) + assert_exit_code(result, expected_exit) + + +@pytest.mark.parametrize( + ("threshold", "expected_exit"), + [("100", 1), ("10", 0)], + ids=["summary-written-on-failure", "summary-written-on-pass"], +) +@verifies("tool_req__coverage_summary_first") +def test_llvm_markdown_summary_is_written_before_the_gate_verdict( + llvm_report: CoverageReport, + tmp_path: Path, + threshold: str, + expected_exit: int, +) -> None: + """The requested Markdown summary is complete on both pass and failure.""" + summary = tmp_path / "coverage-summary.md" + result = generate_report( + llvm_report, + "--yaml", + JUSTIFICATIONS, + "--summary-md", + str(summary), + threshold=threshold, + ) + assert_exit_code(result, expected_exit) + assert summary.is_file(), "summary should be written even when the gate fails" + assert "## Coverage summary" in summary.read_text(encoding="utf-8") + + +@verifies("tool_req__coverage_summary_first") +def test_llvm_github_step_summary_appends_without_overwriting_existing_content( + llvm_report: CoverageReport, tmp_path: Path +) -> None: + """The CI summary appends coverage while preserving earlier step content.""" + summary = tmp_path / "step-summary.md" + summary.write_text("# Earlier step\n", encoding="utf-8") + result = generate_report( + llvm_report, + "--yaml", + JUSTIFICATIONS, + env={"GITHUB_STEP_SUMMARY": str(summary)}, + ) + assert_exit_code(result, 0) + content = summary.read_text(encoding="utf-8") + assert "# Earlier step" in content + assert "## Coverage summary" in content diff --git a/tools/integration_tests/test_llvm_reports.py b/tools/integration_tests/test_llvm_reports.py new file mode 100644 index 0000000..6b81380 --- /dev/null +++ b/tools/integration_tests/test_llvm_reports.py @@ -0,0 +1,181 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Black-box scenarios for LLVM reports, artifacts, and source mapping.""" + +# Keep backend-specific command setup and expected artifacts visible in each +# module; pylint otherwise flags this intentional cross-backend test symmetry. +# pylint: disable=duplicate-code + +import os +import re +import shutil +from pathlib import Path + +import pytest +from _blackbox_support import ( + EXPECTED_UNMAPPED_LLVM, + JUSTIFICATIONS, + CoverageReport, + assert_exit_code, + generate_report, + index_links, + lcov_from_artifacts, + lcov_records, + line_counts, + normalise_lcov, + summary_metric, + verifies, +) + + +@pytest.fixture(name="llvm_effective_archive", scope="module") +def create_llvm_effective_archive(llvm_report: CoverageReport, tmp_path_factory: pytest.TempPathFactory) -> Path: + """Generate one effective-coverage archive for the artifact scenarios.""" + output_directory = os.environ.get("TEST_UNDECLARED_OUTPUTS_DIR") + output_root = Path(output_directory) if output_directory else tmp_path_factory.mktemp("llvm-archive") + archive = output_root / "coverage_artifact" + # Bazel may reuse its undeclared-output directory between runs. + shutil.rmtree(archive, ignore_errors=True) + result = generate_report(llvm_report, "--yaml", JUSTIFICATIONS, "--archive-dir", str(archive)) + assert_exit_code(result, 0) + return archive + + +@pytest.fixture(name="llvm_raw_html_archive", scope="module") +def create_llvm_raw_html_archive(llvm_report: CoverageReport, tmp_path_factory: pytest.TempPathFactory) -> Path: + """Generate raw-coverage HTML at 100%, where the coverage gate should fail.""" + archive = tmp_path_factory.mktemp("llvm-html") / "coverage-artifacts" + result = generate_report(llvm_report, "--archive-dir", str(archive), threshold="100") + assert_exit_code(result, 1) + return archive + + +@verifies("tool_req__coverage_artifacts") +def test_effective_archive_contains_html_lcov_and_justification_summary(llvm_effective_archive: Path) -> None: + """The consumer archive contains its HTML report, LCOV data, and justification summary.""" + assert (llvm_effective_archive / "coverage_linux" / "index.html").is_file() + assert (llvm_effective_archive / "coverage_report.dat").is_file() + assert (llvm_effective_archive / "justification_report" / "summary.txt").is_file() + + +@verifies("tool_req__coverage_report_unmapped") +def test_effective_archive_lists_unmapped_files(llvm_effective_archive: Path) -> None: + """The archive categorizes every file omitted from the LLVM LCOV report.""" + unmapped = (llvm_effective_archive / "unmapped_files.txt").read_text(encoding="utf-8") + assert unmapped == EXPECTED_UNMAPPED_LLVM + + +@verifies("tool_req__coverage_just_markers") +def test_effective_summary_credits_reviewed_justifications(llvm_effective_archive: Path) -> None: + """Reviewed markers raise effective coverage above raw coverage.""" + summary_path = llvm_effective_archive / "justification_report" / "summary.txt" + summary = summary_path.read_text(encoding="utf-8") + raw_coverage = summary_metric(summary, "Raw line coverage") + effective_coverage = summary_metric(summary, "Effective line coverage") + + assert re.search(r"Justified lines:\s+[1-9][0-9]*", summary) + assert effective_coverage > raw_coverage + + +@verifies("tool_req__coverage_validation_ground_truth") +def test_llvm_report_matches_hand_derived_lcov_golden( + llvm_report: CoverageReport, llvm_effective_archive: Path +) -> None: + """The public archive matches the hand-derived C++ and Rust LCOV golden.""" + actual = normalise_lcov(lcov_from_artifacts(llvm_effective_archive)) + golden_path = llvm_report.workspace / "expected_lcov.dat" + expected = normalise_lcov(golden_path.read_text(encoding="utf-8")) + + assert actual == expected + + +@verifies("tool_req__coverage_report_baseline_zero") +def test_lcov_keeps_zero_hit_records_for_uncovered_cpp_and_rust(llvm_effective_archive: Path) -> None: + """Uncovered C++ and Rust sources remain present as zero-hit LCOV records.""" + records = lcov_records(lcov_from_artifacts(llvm_effective_archive)) + for source in ("src/uncovered.cpp", "rust/main.rs"): + counts = line_counts(records, source) + assert counts.found > 0, f"{source} has no executable lines in the LCOV report" + assert counts.hits == 0, f"{source} should have zero covered lines" + + +@verifies("tool_req__coverage_report_unmapped") +def test_uncompiled_header_is_listed_without_an_lcov_record(llvm_effective_archive: Path) -> None: + """An in-scope header that was never compiled is reported separately from LCOV.""" + records = lcov_records(lcov_from_artifacts(llvm_effective_archive)) + unmapped = (llvm_effective_archive / "unmapped_files.txt").read_text(encoding="utf-8") + + assert "src/unused_api.h" not in records + assert "no-data\tsrc/unused_api.h" in unmapped + + +@verifies("tool_req__coverage_report_relative_paths") +def test_html_links_resolve_to_generated_pages(llvm_raw_html_archive: Path) -> None: + """Every link in the HTML index points to a generated report page.""" + html_root = llvm_raw_html_archive / "coverage_linux" + links = index_links(html_root / "index.html") + assert links + + for link in links: + assert (html_root / link).is_file(), f"report page does not exist: {link}" + + +@verifies("tool_req__coverage_report_relative_paths") +def test_html_links_contain_no_machine_specific_paths(llvm_raw_html_archive: Path) -> None: + """Generated links stay workspace-relative instead of exposing local paths.""" + html_root = llvm_raw_html_archive / "coverage_linux" + links = index_links(html_root / "index.html") + assert links + + machine_specific_paths = ("bazel-out", "_virtual_includes", "/home/", "/tmp/") + for link in links: + for path_fragment in machine_specific_paths: + assert path_fragment not in link, f"report link contains {path_fragment}: {link}" + + +@verifies("tool_req__coverage_report_allowlist") +def test_html_includes_declared_external_and_vendored_headers(llvm_raw_html_archive: Path) -> None: + """The source allowlist includes declared external and vendored headers.""" + html_root = llvm_raw_html_archive / "coverage_linux" + links = index_links(html_root / "index.html") + external_header = "coverage/external/itest_external+/include/vext/vext.h.html" + vendored_header = "coverage/src/vendored/include/vendored/inline_math.h.html" + assert external_header in links, f"missing external header page: {links}" + assert vendored_header in links, f"missing vendored header page: {links}" + + +@verifies("tool_req__coverage_report_allowlist") +def test_html_excludes_forwarded_external_library(llvm_raw_html_archive: Path) -> None: + """Forwarded external library code is absent from the HTML and LCOV outputs.""" + html_root = llvm_raw_html_archive / "coverage_linux" + index = (html_root / "index.html").read_text(encoding="utf-8") + lcov = lcov_from_artifacts(llvm_raw_html_archive) + + assert "itest_external+/extlib" not in index + assert "extlib" not in lcov + + +@verifies("tool_req__coverage_report_relative_paths") +def test_html_pages_link_to_existing_stylesheets(llvm_raw_html_archive: Path) -> None: + """Every HTML source page links to a stylesheet that exists beside it.""" + html_root = llvm_raw_html_archive / "coverage_linux" + links = index_links(html_root / "index.html") + + for link in links: + page = html_root / link + stylesheet = re.search( + r"href=['\"]((?:\.\./)*style\.css)['\"]", + page.read_text(encoding="utf-8"), + ) + assert stylesheet, f"{link} does not reference its stylesheet" + assert (page.parent / stylesheet.group(1)).is_file(), f"{link} stylesheet does not resolve"