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:
parent
aee9b98f0e
commit
74e2640262
3 changed files with 191 additions and 2 deletions
19
.github/workflows/ci.yaml
vendored
19
.github/workflows/ci.yaml
vendored
|
|
@ -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
4
.gitignore
vendored
|
|
@ -8,3 +8,7 @@ pubspec.lock
|
|||
|
||||
.vscode/
|
||||
*.g.dart
|
||||
|
||||
# Coverage reports
|
||||
coverage/
|
||||
coverage-results.txt
|
||||
|
|
|
|||
170
scripts/coverage_report.dart
Normal file
170
scripts/coverage_report.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue