Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: marcelomendoncasoares <6807059+marcelomendoncasoares@users.noreply.github.com>
170 lines
4.9 KiB
Dart
170 lines
4.9 KiB
Dart
// 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,
|
|
};
|
|
}
|