serverpod-offline-sync/scripts/coverage_report.dart

170 lines
4.9 KiB
Dart
Raw Normal View History

// 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,
};
}