Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,10 @@ very_good test -r
Run tests in a Dart or Flutter project.

Usage: very_good test [arguments]
-h, --help Print this usage information.
-r, --recursive Run tests recursively for all nested packages.
-h, --help Print this usage information.
-r, --recursive Run tests recursively for all nested packages.
--coverage Whether to collect coverage information.
--min-coverage Whether to enforce a minimum coverage percentage.

Run "very_good help" to see global options.
```
Expand Down
1 change: 1 addition & 0 deletions lib/src/cli/cli.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';

import 'package:lcov_parser/lcov_parser.dart';
import 'package:mason/mason.dart';
import 'package:path/path.dart' as p;
import 'package:universal_io/io.dart';
Expand Down
88 changes: 85 additions & 3 deletions lib/src/cli/flutter_cli.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,49 @@ part of 'cli.dart';
/// is executed without a `pubspec.yaml`.
class PubspecNotFound implements Exception {}

/// {@template coverage_not_met}
/// Thrown when `flutter test ---coverage --min-coverage`
/// does not meet the provided minimum coverage threshold.
/// {@endtemplate}
class MinCoverageNotMet implements Exception {
/// {@macro coverage_not_met}
const MinCoverageNotMet(this.coverage);

/// The measured coverage percentage (total hits / total found * 100).
final double coverage;
}

/// Thrown when `flutter test ---coverage --min-coverage value`
/// does not generate the coverage file within the timeout threshold.
class GenerateCoverageTimeout implements Exception {
@override
String toString() => 'Timed out waiting for coverage to be generated.';
}

class _CoverageMetrics {
const _CoverageMetrics._({this.totalHits = 0, this.totalFound = 0});

/// Generate coverage metrics from a list of lcov records.
factory _CoverageMetrics.fromLcovRecords(List<Record> records) {
return records.fold<_CoverageMetrics>(
const _CoverageMetrics._(),
(current, record) {
final found = record.lines?.found ?? 0;
final hit = record.lines?.hit ?? 0;
return _CoverageMetrics._(
totalFound: current.totalFound + found,
totalHits: current.totalHits + hit,
);
},
);
}

final int totalHits;
final int totalFound;

double get percentage => totalFound < 1 ? 0 : (totalHits / totalFound * 100);
}

/// Flutter CLI
class Flutter {
/// Determine whether flutter is installed.
Expand Down Expand Up @@ -62,22 +105,40 @@ class Flutter {
static Future<void> test({
String cwd = '.',
bool recursive = false,
bool collectCoverage = false,
double? minCoverage,
void Function(String)? stdout,
void Function(String)? stderr,
}) {
return _runCommand(
}) async {
final lcovPath = p.join(cwd, 'coverage', 'lcov.info');
final lcovFile = File(lcovPath);

if (collectCoverage && lcovFile.existsSync()) {
await lcovFile.delete();
}

await _runCommand(
cmd: (cwd) {
void noop(String? _) {}
stdout?.call('Running "flutter test" in $cwd...\n');
return _flutterTest(
cwd: cwd,
collectCoverage: collectCoverage,
stdout: stdout ?? noop,
stderr: stderr ?? noop,
);
},
cwd: cwd,
recursive: recursive,
);

if (collectCoverage) await lcovFile.ensureCreated();
if (minCoverage != null) {
final records = await Parser.parse(lcovPath);
final coverageMetrics = _CoverageMetrics.fromLcovRecords(records);
final coverage = coverageMetrics.percentage;
if (coverage < minCoverage) throw MinCoverageNotMet(coverage);
}
}
}

Expand Down Expand Up @@ -110,6 +171,7 @@ Future<void> _runCommand<T>({

Future<void> _flutterTest({
String cwd = '.',
bool collectCoverage = false,
required void Function(String) stdout,
required void Function(String) stderr,
}) {
Expand Down Expand Up @@ -142,7 +204,13 @@ Future<void> _flutterTest({
},
);

flutterTest(workingDirectory: cwd, runInShell: true).listen(
flutterTest(
workingDirectory: cwd,
arguments: [
if (collectCoverage) '--coverage',
],
runInShell: true,
).listen(
(event) {
if (event.shouldCancelTimer()) timerSubscription.cancel();
if (event is SuiteTestEvent) suites[event.suite.id] = event.suite;
Expand Down Expand Up @@ -217,6 +285,20 @@ final int _lineLength = () {
}
}();

extension on File {
Future<void> ensureCreated({
Duration timeout = const Duration(seconds: 1),
Duration interval = const Duration(milliseconds: 50),
}) async {
var elapsedTime = Duration.zero;
while (!existsSync()) {
await Future<void>.delayed(interval);
elapsedTime += interval;
if (elapsedTime >= timeout) throw GenerateCoverageTimeout();
}
}
}

extension on TestEvent {
bool shouldCancelTimer() {
final event = this;
Expand Down
33 changes: 27 additions & 6 deletions lib/src/commands/test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,22 @@ import 'package:very_good_cli/src/cli/cli.dart';
class TestCommand extends Command<int> {
/// {@macro test_command}
TestCommand({Logger? logger}) : _logger = logger ?? Logger() {
argParser.addFlag(
'recursive',
abbr: 'r',
help: 'Run tests recursively for all nested packages.',
negatable: false,
);
argParser
..addFlag(
'recursive',
abbr: 'r',
help: 'Run tests recursively for all nested packages.',
negatable: false,
)
..addFlag(
'coverage',
help: 'Whether to collect coverage information.',
negatable: false,
)
..addOption(
'min-coverage',
help: 'Whether to enforce a minimum coverage percentage.',
);
}

final Logger _logger;
Expand All @@ -43,6 +53,10 @@ class TestCommand extends Command<int> {
final recursive = _argResults['recursive'] as bool;
final target = _argResults.rest.length == 1 ? _argResults.rest[0] : '.';
final targetPath = path.normalize(Directory(target).absolute.path);
final collectCoverage = _argResults['coverage'] as bool;
final minCoverage = double.tryParse(
_argResults['min-coverage'] as String? ?? '',
);
final isFlutterInstalled = await Flutter.installed();
if (isFlutterInstalled) {
try {
Expand All @@ -51,10 +65,17 @@ class TestCommand extends Command<int> {
recursive: recursive,
stdout: _logger.write,
stderr: _logger.err,
collectCoverage: collectCoverage,
minCoverage: minCoverage,
);
} on PubspecNotFound catch (_) {
_logger.err('Could not find a pubspec.yaml in $targetPath');
return ExitCode.noInput.code;
} on MinCoverageNotMet catch (e) {
_logger.err(
'''Expected coverage >= ${minCoverage!.toStringAsFixed(2)}% but actual is ${e.coverage.toStringAsFixed(2)}%.''',
);
return ExitCode.unavailable.code;
} catch (error) {
_logger.err('$error');
return ExitCode.unavailable.code;
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ environment:

dependencies:
args: ^2.1.0
lcov_parser: ^0.1.2
mason: ">=0.1.0-dev.9 <0.1.0-dev.10"
mason_logger: ^0.1.0-dev.6
meta: ^1.3.0
Expand Down
Loading