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
|
needs: generate
|
||||||
name: dart test
|
name: dart test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Dart
|
- name: Set up Dart
|
||||||
|
|
@ -81,8 +84,20 @@ jobs:
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: generated-code
|
name: generated-code
|
||||||
- name: Run tests
|
- name: Run tests with coverage
|
||||||
run: dart test --reporter=failures-only
|
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:
|
benchmarks:
|
||||||
needs: generate
|
needs: generate
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -8,3 +8,7 @@ pubspec.lock
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
*.g.dart
|
*.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