|
| 1 | +#!/usr/bin/env bash |
| 2 | +# |
| 3 | +# coverage.sh — run a SwiftPM test suite with code coverage, export LCOV and |
| 4 | +# Cobertura reports, and enforce a minimum line-coverage threshold. |
| 5 | +# |
| 6 | +# Coverage is measured against the package's OWN sources only (everything under |
| 7 | +# the repo's `Sources/` directory). Dependencies (checked out under `.build`), |
| 8 | +# generated sources, and the test target are excluded, so the number reflects |
| 9 | +# the coverage of this package's code rather than being diluted by third-party |
| 10 | +# code that the tests happen to exercise. |
| 11 | +# |
| 12 | +# The Cobertura XML (coverage.xml) is the format GitHub Code Quality consumes via |
| 13 | +# `actions/upload-code-coverage`; the LCOV report (coverage.lcov) is kept for |
| 14 | +# other tools (Codecov, Coveralls, editors) that prefer it. |
| 15 | +# |
| 16 | +# Usage: |
| 17 | +# Scripts/coverage.sh [threshold] |
| 18 | +# |
| 19 | +# Environment variables: |
| 20 | +# COVERAGE_THRESHOLD Minimum line coverage percentage (default: 80). |
| 21 | +# Overridden by the optional [threshold] argument. |
| 22 | +# COVERAGE_SOURCE_PREFIX Absolute path prefix selecting the sources that count |
| 23 | +# toward coverage (default: "$PWD/Sources/"). Narrow it |
| 24 | +# to a single target, e.g. "$PWD/Sources/MyLibrary/", to |
| 25 | +# gate on one target instead of every source file. |
| 26 | +# COVERAGE_OUTPUT LCOV output file (default: .build/coverage/coverage.lcov). |
| 27 | +# COBERTURA_OUTPUT Cobertura XML output file (default: .build/coverage/coverage.xml). |
| 28 | +# SKIP_TEST If set to 1, reuse existing coverage data instead of |
| 29 | +# re-running `swift test` (useful while iterating). |
| 30 | + |
| 31 | +set -euo pipefail |
| 32 | + |
| 33 | +THRESHOLD="${1:-${COVERAGE_THRESHOLD:-80}}" |
| 34 | +SOURCE_PREFIX="${COVERAGE_SOURCE_PREFIX:-$PWD/Sources/}" |
| 35 | +OUTPUT="${COVERAGE_OUTPUT:-.build/coverage/coverage.lcov}" |
| 36 | +COBERTURA_OUTPUT="${COBERTURA_OUTPUT:-.build/coverage/coverage.xml}" |
| 37 | + |
| 38 | +# Resolve the correct llvm-cov (xcrun on Apple platforms, plain llvm-cov elsewhere). |
| 39 | +if command -v xcrun >/dev/null 2>&1; then |
| 40 | + LLVM_COV="xcrun llvm-cov" |
| 41 | +else |
| 42 | + LLVM_COV="llvm-cov" |
| 43 | +fi |
| 44 | + |
| 45 | +# 1. Run the tests with coverage instrumentation. |
| 46 | +if [ "${SKIP_TEST:-0}" != "1" ]; then |
| 47 | + echo "==> Running tests with code coverage" |
| 48 | + swift test --enable-code-coverage |
| 49 | +fi |
| 50 | + |
| 51 | +# 2. Locate the coverage artifacts SwiftPM produced. |
| 52 | +CODECOV_JSON="$(swift test --enable-code-coverage --show-codecov-path)" |
| 53 | +COV_DIR="$(dirname "$CODECOV_JSON")" |
| 54 | +PROFDATA="$COV_DIR/default.profdata" |
| 55 | + |
| 56 | +if [ ! -f "$CODECOV_JSON" ]; then |
| 57 | + echo "error: coverage report not found at $CODECOV_JSON" >&2 |
| 58 | + exit 1 |
| 59 | +fi |
| 60 | + |
| 61 | +# 3. Locate the instrumented test binary (differs by platform: on macOS it lives |
| 62 | +# inside the .xctest bundle, on Linux the .xctest file is the binary itself). |
| 63 | +BIN_PATH="$(swift build --show-bin-path)" |
| 64 | +TEST_BINARY="" |
| 65 | +for candidate in \ |
| 66 | + "$BIN_PATH"/*PackageTests.xctest/Contents/MacOS/*PackageTests \ |
| 67 | + "$BIN_PATH"/*PackageTests.xctest; do |
| 68 | + if [ -f "$candidate" ]; then |
| 69 | + TEST_BINARY="$candidate" |
| 70 | + break |
| 71 | + fi |
| 72 | +done |
| 73 | + |
| 74 | +# 4. Export an LCOV report (for Codecov / Coveralls / editors). |
| 75 | +mkdir -p "$(dirname "$OUTPUT")" |
| 76 | +if [ -n "$TEST_BINARY" ] && [ -f "$PROFDATA" ]; then |
| 77 | + echo "==> Exporting LCOV report to $OUTPUT" |
| 78 | + $LLVM_COV export \ |
| 79 | + -format=lcov \ |
| 80 | + -instr-profile "$PROFDATA" \ |
| 81 | + "$TEST_BINARY" \ |
| 82 | + -ignore-filename-regex='.build/(checkouts|.*\.build)/|Tests/|\.derived/|DerivedSources/' \ |
| 83 | + > "$OUTPUT" |
| 84 | +else |
| 85 | + echo "warning: could not locate test binary or profdata; skipping LCOV export" >&2 |
| 86 | +fi |
| 87 | + |
| 88 | +# 5. Generate a Cobertura XML report (for GitHub Code Quality / upload-code-coverage). |
| 89 | +echo "==> Writing Cobertura report to $COBERTURA_OUTPUT" |
| 90 | +mkdir -p "$(dirname "$COBERTURA_OUTPUT")" |
| 91 | +python3 - "$CODECOV_JSON" "$SOURCE_PREFIX" "$PWD" "$COBERTURA_OUTPUT" <<'PY' |
| 92 | +import json, os, sys, time |
| 93 | +from xml.sax.saxutils import escape, quoteattr |
| 94 | +
|
| 95 | +report_path, source_prefix, repo_root, output = sys.argv[1:5] |
| 96 | +
|
| 97 | +with open(report_path) as f: |
| 98 | + report = json.load(f) |
| 99 | +
|
| 100 | +files = [] |
| 101 | +total_covered = total_lines = 0 |
| 102 | +for file in report["data"][0]["files"]: |
| 103 | + name = file["filename"] |
| 104 | + if not name.startswith(source_prefix): |
| 105 | + continue |
| 106 | + # Per-line hit count: the greatest region count starting on each line (a line |
| 107 | + # is covered if any region on it executed, so branch sub-regions don't hide it). |
| 108 | + line_hits = {} |
| 109 | + for seg in file["segments"]: |
| 110 | + line, count, has_count = seg[0], seg[2], seg[3] |
| 111 | + if has_count: |
| 112 | + line_hits[line] = max(line_hits.get(line, 0), count) |
| 113 | + covered = sum(1 for h in line_hits.values() if h > 0) |
| 114 | + total_covered += covered |
| 115 | + total_lines += len(line_hits) |
| 116 | + files.append((os.path.relpath(name, repo_root), line_hits, covered, len(line_hits))) |
| 117 | +
|
| 118 | +def rate(c, t): |
| 119 | + return c / t if t else 0.0 |
| 120 | +
|
| 121 | +overall = rate(total_covered, total_lines) |
| 122 | +timestamp = int(time.time()) |
| 123 | +
|
| 124 | +out = [ |
| 125 | + '<?xml version="1.0" ?>', |
| 126 | + '<!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">', |
| 127 | + '<coverage line-rate="%.4f" branch-rate="0" lines-covered="%d" lines-valid="%d" ' |
| 128 | + 'branches-covered="0" branches-valid="0" complexity="0" version="llvm-cov" timestamp="%d">' |
| 129 | + % (overall, total_covered, total_lines, timestamp), |
| 130 | + " <sources>", |
| 131 | + " <source>%s</source>" % escape(repo_root), |
| 132 | + " </sources>", |
| 133 | + " <packages>", |
| 134 | + ' <package name="%s" line-rate="%.4f" branch-rate="0" complexity="0">' |
| 135 | + % (escape(os.path.basename(repo_root)), overall), |
| 136 | + " <classes>", |
| 137 | +] |
| 138 | +for rel, line_hits, covered, total in sorted(files): |
| 139 | + out.append( |
| 140 | + ' <class name=%s filename=%s line-rate="%.4f" branch-rate="0" complexity="0">' |
| 141 | + % (quoteattr(os.path.basename(rel)), quoteattr(rel), rate(covered, total)) |
| 142 | + ) |
| 143 | + out.append(" <methods/>") |
| 144 | + out.append(" <lines>") |
| 145 | + for line in sorted(line_hits): |
| 146 | + out.append(' <line number="%d" hits="%d"/>' % (line, line_hits[line])) |
| 147 | + out.append(" </lines>") |
| 148 | + out.append(" </class>") |
| 149 | +out += [" </classes>", " </package>", " </packages>", "</coverage>"] |
| 150 | +
|
| 151 | +with open(output, "w") as f: |
| 152 | + f.write("\n".join(out) + "\n") |
| 153 | +
|
| 154 | +print(" %d/%d lines (%.2f%%)" % (total_covered, total_lines, 100 * overall)) |
| 155 | +PY |
| 156 | + |
| 157 | +# 6. Compute line coverage for the package sources and enforce the threshold. |
| 158 | +echo "==> Computing coverage for ${SOURCE_PREFIX}" |
| 159 | +python3 - "$CODECOV_JSON" "$SOURCE_PREFIX" "$THRESHOLD" "$PWD" <<'PY' |
| 160 | +import json, os, sys |
| 161 | +
|
| 162 | +report_path, source_prefix, threshold, repo_root = sys.argv[1], sys.argv[2], float(sys.argv[3]), sys.argv[4] |
| 163 | +
|
| 164 | +with open(report_path) as f: |
| 165 | + report = json.load(f) |
| 166 | +
|
| 167 | +covered = total = 0 |
| 168 | +rows = [] |
| 169 | +for file in report["data"][0]["files"]: |
| 170 | + name = file["filename"] |
| 171 | + if not name.startswith(source_prefix): |
| 172 | + continue |
| 173 | + lines = file["summary"]["lines"] |
| 174 | + covered += lines["covered"] |
| 175 | + total += lines["count"] |
| 176 | + rows.append((lines["percent"], os.path.relpath(name, repo_root))) |
| 177 | +
|
| 178 | +if total == 0: |
| 179 | + print("error: no source files matched prefix %r" % source_prefix, file=sys.stderr) |
| 180 | + print(" (set COVERAGE_SOURCE_PREFIX to your package's Sources path)", file=sys.stderr) |
| 181 | + sys.exit(1) |
| 182 | +
|
| 183 | +percent = 100.0 * covered / total |
| 184 | +
|
| 185 | +for pct, name in sorted(rows): |
| 186 | + print(" %6.2f%% %s" % (pct, name)) |
| 187 | +
|
| 188 | +print("-" * 48) |
| 189 | +print("Total line coverage: %.2f%% (%d/%d lines)" % (percent, covered, total)) |
| 190 | +print("Required threshold: %.2f%%" % threshold) |
| 191 | +
|
| 192 | +if percent < threshold: |
| 193 | + print("FAILED: coverage %.2f%% is below the %.2f%% threshold" % (percent, threshold), file=sys.stderr) |
| 194 | + sys.exit(1) |
| 195 | +
|
| 196 | +print("PASSED: coverage meets the threshold") |
| 197 | +PY |
0 commit comments