feat: Add test coverage reporting to PRs (#8)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcelomendoncasoares <6807059+marcelomendoncasoares@users.noreply.github.com>
This commit is contained in:
Copilot 2026-01-29 21:21:48 -03:00 • committed by GitHub
commit 74e2640262
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 191 additions and 2 deletions

View file

@ -69,6 +69,9 @@ jobs:
needs: generate
name: dart test
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Set up Dart
@ -81,8 +84,20 @@ jobs:
uses: actions/download-artifact@v4
with:
name: generated-code
- name: Run tests
run: dart test --reporter=failures-only
- name: Run tests with coverage
run: dart test --coverage=coverage --reporter=failures-only
- name: Activate coverage package
run: dart pub global activate coverage
- name: Format coverage
run: dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib --check-ignore --ignore-files="**/*.g.dart"
- name: Generate coverage report
run: dart scripts/coverage_report.dart --ci > coverage-results.txt
- name: Comment on PR
if: github.event_name == 'pull_request'
uses: thollander/actions-comment-pull-request@v3
with:
file-path: coverage-results.txt
comment-tag: coverage-results
benchmarks:
needs: generate

4
.gitignore vendored
View file

@ -8,3 +8,7 @@ pubspec.lock
.vscode/
*.g.dart
# Coverage reports
coverage/
coverage-results.txt

View file

@ -0,0 +1,170 @@
// Coverage Reporter
//
// This script generates markdown-formatted test coverage reports for GitHub PRs.
//
// ## Usage
//
// The script reads an `lcov.info` file generated by the Dart coverage tool and
// outputs a markdown report showing:
// - Overall coverage percentage
// - Lines covered vs total lines
// - Per-file coverage breakdown sorted by coverage percentage
//
// ### Local Usage
//
// ```bash
// # Run tests with coverage
// dart test --coverage=coverage
//
// # Format coverage to lcov
// dart pub global activate coverage
// dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib --check-ignore --ignore-files="**/*.g.dart"
//
// # Generate markdown report
// dart scripts/coverage_report.dart
//
// # For CI mode (no decorations)
// dart scripts/coverage_report.dart --ci
// ```
//
// ### CI Usage
//
// This script is automatically run as part of the CI pipeline in the `tests`
// job. The generated report is posted as a comment on pull requests.
import 'dart:io';
Future<void> main(List<String> args) async {
final runningInCI = args.contains('--ci');
final lcovFile = File('coverage/lcov.info');
if (!lcovFile.existsSync()) {
stderr.writeln('Error: coverage/lcov.info not found');
exit(1);
}
final String lcovContent;
try {
lcovContent = lcovFile.readAsStringSync();
} on Exception catch (e) {
stderr.writeln('Error reading coverage/lcov.info: $e');
exit(1);
}
final coverage = _parseLcov(lcovContent);
final totalLines = coverage['total_lines'] as int;
final coveredLines = coverage['covered_lines'] as int;
final percentage = totalLines > 0
? (coveredLines / totalLines * 100).toStringAsFixed(2)
: '0.00';
if (!runningInCI) print('\n${'=' * 60}');
print('## Test Coverage Report\n');
print(
[
'This report shows the test coverage for the project.',
].join(runningInCI ? '' : '\n'),
);
if (!runningInCI) print('-' * 60);
print('');
print('**Total Coverage: $percentage%**');
print('');
print('- Lines covered: $coveredLines / $totalLines');
print('');
// Print per-file coverage in a collapsible section
final files = coverage['files'] as Map<String, Map<String, int>>;
if (files.isNotEmpty) {
print('<details>');
print('<summary>File Coverage</summary>');
print('');
final sortedFiles = files.entries.toList()
..sort((a, b) {
final aTotal = a.value['total']!;
final bTotal = b.value['total']!;
if (aTotal == 0 && bTotal == 0) return 0;
if (aTotal == 0) return 1;
if (bTotal == 0) return -1;
final aPercent = a.value['covered']! / aTotal;
final bPercent = b.value['covered']! / bTotal;
return bPercent.compareTo(aPercent);
});
for (final entry in sortedFiles) {
final fileName = entry.key.split('/').last;
final covered = entry.value['covered']!;
final total = entry.value['total']!;
final filePercentage = total > 0
? (covered / total * 100).toStringAsFixed(1)
: '0.0';
print('- `$fileName`: $filePercentage% ($covered/$total lines)');
}
print('');
print('</details>');
}
if (!runningInCI) print('\n${'=' * 60}\n');
}
Map<String, dynamic> _parseLcov(String lcovContent) {
final lines = lcovContent.split('\n');
var totalLines = 0;
var coveredLines = 0;
final files = <String, Map<String, int>>{};
String? currentFile;
var currentFileLines = 0;
var currentFileCovered = 0;
for (final line in lines) {
if (line.startsWith('SF:')) {
// Save previous file data if it exists (handles malformed lcov)
if (currentFile != null && currentFileLines > 0) {
totalLines += currentFileLines;
coveredLines += currentFileCovered;
files[currentFile] = {
'total': currentFileLines,
'covered': currentFileCovered,
};
}
currentFile = line.substring(3);
currentFileLines = 0;
currentFileCovered = 0;
} else if (line.startsWith('DA:')) {
final parts = line.substring(3).split(',');
if (parts.length >= 2) {
final hits = int.tryParse(parts[1]) ?? 0;
currentFileLines++;
if (hits > 0) {
currentFileCovered++;
}
}
} else if (line == 'end_of_record' && currentFile != null) {
if (currentFileLines > 0) {
totalLines += currentFileLines;
coveredLines += currentFileCovered;
files[currentFile] = {
'total': currentFileLines,
'covered': currentFileCovered,
};
}
currentFile = null;
}
}
// Handle case where file ends without 'end_of_record'
if (currentFile != null && currentFileLines > 0) {
totalLines += currentFileLines;
coveredLines += currentFileCovered;
files[currentFile] = {
'total': currentFileLines,
'covered': currentFileCovered,
};
}
return {
'total_lines': totalLines,
'covered_lines': coveredLines,
'files': files,
};
}