Skip to content

Commit 5ce98f0

Browse files
committed
build: stabilize coverage export
1 parent 2f9273e commit 5ce98f0

File tree

2 files changed

+111
-18
lines changed

2 files changed

+111
-18
lines changed

.github/workflows/test.yml

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ jobs:
2828
-destination 'platform=macOS' \
2929
-enableCodeCoverage YES \
3030
-derivedDataPath DerivedData \
31+
-resultBundlePath DerivedData/Logs/Test/Simmer.xcresult \
3132
CODE_SIGN_IDENTITY="" \
3233
CODE_SIGNING_REQUIRED=NO \
3334
CODE_SIGNING_ALLOWED=NO \
@@ -39,6 +40,7 @@ jobs:
3940
run: |
4041
# Create coverage directory
4142
mkdir -p coverage
43+
XCRESULT_PATH="DerivedData/Logs/Test/Simmer.xcresult"
4244
4345
# Try xcov first (likely not installed on runner)
4446
xcrun xcov \
@@ -53,25 +55,14 @@ jobs:
5355
xcrun xccov view \
5456
--report \
5557
--json \
56-
DerivedData/Logs/Test/*.xcresult > coverage/coverage.json
58+
"$XCRESULT_PATH" > coverage/coverage.json
5759
fi
5860
5961
- name: Convert coverage to LCOV format
6062
run: |
61-
# Find the xcresult bundle
62-
XCRESULT_PATH=$(find DerivedData/Logs/Test -name "*.xcresult" | head -n 1)
63-
64-
# Find the profdata file
65-
PROFDATA_PATH=$(find "$XCRESULT_PATH" -name "*.profdata" | head -n 1)
66-
67-
# Find the app binary
68-
BINARY_PATH="DerivedData/Build/Products/Debug/Simmer.app/Contents/MacOS/Simmer"
69-
70-
# Convert to lcov format
71-
xcrun llvm-cov export \
72-
-format="lcov" \
73-
-instr-profile="$PROFDATA_PATH" \
74-
"$BINARY_PATH" > coverage/coverage.lcov
63+
python3 scripts/xccov_to_lcov.py \
64+
DerivedData/Logs/Test/Simmer.xcresult \
65+
coverage/coverage.lcov
7566
7667
echo "✅ Converted coverage to LCOV format"
7768
@@ -101,7 +92,8 @@ jobs:
10192
fi
10293
10394
# Parse overall coverage percentage
104-
OVERALL_COVERAGE=$(xcrun xccov view --report DerivedData/Logs/Test/*.xcresult | grep -E "^\s+Simmer.app" | awk '{print $3}' | sed 's/%//')
95+
XCRESULT_PATH="DerivedData/Logs/Test/Simmer.xcresult"
96+
OVERALL_COVERAGE=$(xcrun xccov view --report "$XCRESULT_PATH" | grep -E "^\s+Simmer.app" | awk '{print $3}' | sed 's/%//')
10597
10698
echo "Overall coverage: ${OVERALL_COVERAGE}%"
10799
@@ -116,8 +108,8 @@ jobs:
116108
# Check critical paths (PatternMatcher, FileWatcher) have 100% coverage
117109
echo "Checking critical path coverage..."
118110
119-
PATTERN_MATCHER_COV=$(xcrun xccov view --report --files-for-target Simmer.app DerivedData/Logs/Test/*.xcresult | grep "PatternMatcher.swift" | awk '{print $4}' | sed 's/%//' || echo "0")
120-
FILE_WATCHER_COV=$(xcrun xccov view --report --files-for-target Simmer.app DerivedData/Logs/Test/*.xcresult | grep "FileWatcher.swift" | awk '{print $4}' | sed 's/%//' || echo "0")
111+
PATTERN_MATCHER_COV=$(xcrun xccov view --report --files-for-target Simmer.app "$XCRESULT_PATH" | grep "PatternMatcher.swift" | awk '{print $4}' | sed 's/%//' || echo "0")
112+
FILE_WATCHER_COV=$(xcrun xccov view --report --files-for-target Simmer.app "$XCRESULT_PATH" | grep "FileWatcher.swift" | awk '{print $4}' | sed 's/%//' || echo "0")
121113
122114
echo "PatternMatcher.swift coverage: ${PATTERN_MATCHER_COV}%"
123115
echo "FileWatcher.swift coverage: ${FILE_WATCHER_COV}%"

scripts/xccov_to_lcov.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Convert an Xcode .xcresult coverage archive to LCOV format.
4+
5+
This script consumes the JSON output produced by `xcrun xccov` and emits
6+
LCOV records so downstream tooling (e.g. Codecov) can ingest coverage.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import subprocess
13+
import sys
14+
from pathlib import Path
15+
16+
17+
def run_xccov(args: list[str]) -> str:
18+
"""Execute xccov with the provided arguments and return stdout."""
19+
result = subprocess.run(
20+
["xcrun", "xccov", *args],
21+
check=True,
22+
capture_output=True,
23+
text=True,
24+
)
25+
return result.stdout
26+
27+
28+
def generate_lcov(xcresult_path: Path, output_path: Path, repo_root: Path) -> None:
29+
report = json.loads(
30+
run_xccov(["view", "--report", "--json", str(xcresult_path)])
31+
)
32+
33+
lines = []
34+
for target in report.get("targets", []):
35+
# Only include the Simmer app target; skip tests and UI harnesses.
36+
if not target.get("name", "").endswith(".app"):
37+
continue
38+
39+
for file_entry in target.get("files", []):
40+
file_path = Path(file_entry.get("path", ""))
41+
if not file_path.exists():
42+
# If the file no longer exists in the workspace, skip it.
43+
continue
44+
45+
try:
46+
rel_path = file_path.relative_to(repo_root)
47+
except ValueError:
48+
# Skip files outside the repository (e.g. system headers).
49+
continue
50+
51+
file_json = json.loads(
52+
run_xccov(
53+
[
54+
"view",
55+
"--archive",
56+
"--file",
57+
str(file_path),
58+
"--json",
59+
str(xcresult_path),
60+
]
61+
)
62+
)
63+
file_key = next(iter(file_json.keys()))
64+
coverage_entries = file_json[file_key]
65+
66+
lines.append(f"TN:{target['name']}")
67+
lines.append(f"SF:{rel_path}")
68+
for entry in coverage_entries:
69+
if not entry.get("isExecutable", False):
70+
continue
71+
line_no = entry["line"]
72+
exec_count = entry.get("executionCount", 0)
73+
lines.append(f"DA:{line_no},{exec_count}")
74+
lines.append("end_of_record")
75+
76+
output_path.write_text("\n".join(lines) + "\n")
77+
78+
79+
def main() -> int:
80+
if len(sys.argv) != 3:
81+
print(
82+
"usage: scripts/xccov_to_lcov.py <path/to/result.xcresult> <output.lcov>",
83+
file=sys.stderr,
84+
)
85+
return 1
86+
87+
xcresult = Path(sys.argv[1]).resolve()
88+
output = Path(sys.argv[2])
89+
repo_root = Path.cwd()
90+
91+
if not xcresult.exists():
92+
print(f"error: xcresult not found at {xcresult}", file=sys.stderr)
93+
return 1
94+
95+
output.parent.mkdir(parents=True, exist_ok=True)
96+
generate_lcov(xcresult, output, repo_root)
97+
return 0
98+
99+
100+
if __name__ == "__main__":
101+
sys.exit(main())

0 commit comments

Comments
 (0)