// 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 main(List 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>; if (files.isNotEmpty) { print('
'); print('File Coverage'); 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('
'); } if (!runningInCI) print('\n${'=' * 60}\n'); } Map _parseLcov(String lcovContent) { final lines = lcovContent.split('\n'); var totalLines = 0; var coveredLines = 0; final files = >{}; 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, }; }