From 8df95792f6ae5a6d76e9f6421fd95716594100c5 Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Fri, 8 Aug 2025 17:55:32 -0300 Subject: [PATCH 01/11] feat: Adding the dart test command --- lib/src/cli/cli.dart | 4 +- lib/src/cli/common.dart | 1 + lib/src/cli/dart_cli.dart | 37 ++ lib/src/cli/flutter_cli.dart | 360 +------------- .../test => cli}/templates/templates.dart | 0 .../templates/test_optimizer_bundle.dart | 0 lib/src/cli/test_cli_runner.dart | 441 ++++++++++++++++++ lib/src/command_runner.dart | 1 + lib/src/commands/commands.dart | 1 + lib/src/commands/dart/commands/commands.dart | 1 + .../dart/commands/dart_test_commands.dart | 187 ++++++++ lib/src/commands/dart/dart.dart | 19 + lib/src/commands/test/test.dart | 44 +- 13 files changed, 709 insertions(+), 387 deletions(-) create mode 100644 lib/src/cli/common.dart rename lib/src/{commands/test => cli}/templates/templates.dart (100%) rename lib/src/{commands/test => cli}/templates/test_optimizer_bundle.dart (100%) create mode 100644 lib/src/cli/test_cli_runner.dart create mode 100644 lib/src/commands/dart/commands/commands.dart create mode 100644 lib/src/commands/dart/commands/dart_test_commands.dart create mode 100644 lib/src/commands/dart/dart.dart diff --git a/lib/src/cli/cli.dart b/lib/src/cli/cli.dart index 353c71db4..bf1b750e7 100644 --- a/lib/src/cli/cli.dart +++ b/lib/src/cli/cli.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math'; import 'package:collection/collection.dart'; import 'package:glob/glob.dart'; import 'package:lcov_parser/lcov_parser.dart'; @@ -7,12 +8,13 @@ import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'package:pubspec_parse/pubspec_parse.dart'; import 'package:universal_io/io.dart'; -import 'package:very_good_cli/src/commands/test/templates/templates.dart'; +import 'package:very_good_cli/src/cli/templates/templates.dart'; import 'package:very_good_test_runner/very_good_test_runner.dart'; part 'dart_cli.dart'; part 'flutter_cli.dart'; part 'git_cli.dart'; +part 'test_cli_runner.dart'; const R Function( R Function(), { diff --git a/lib/src/cli/common.dart b/lib/src/cli/common.dart new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/lib/src/cli/common.dart @@ -0,0 +1 @@ + diff --git a/lib/src/cli/dart_cli.dart b/lib/src/cli/dart_cli.dart index 628c9dc50..35a98669c 100644 --- a/lib/src/cli/dart_cli.dart +++ b/lib/src/cli/dart_cli.dart @@ -92,4 +92,41 @@ class Dart { await Future.wait(processes); } + + /// Run tests (`flutter test`). + /// Returns a list of exit codes for each test process. + static Future> test({ + required Logger logger, + String cwd = '.', + bool recursive = false, + bool collectCoverage = false, + bool optimizePerformance = false, + Set ignore = const {}, + double? minCoverage, + String? excludeFromCoverage, + String? randomSeed, + bool? forceAnsi, + List? arguments, + void Function(String)? stdout, + void Function(String)? stderr, + GeneratorBuilder buildGenerator = MasonGenerator.fromBundle, + }) async { + return TestCLIRunner.test( + logger: logger, + testType: TestRunType.dart, + cwd: cwd, + recursive: recursive, + collectCoverage: collectCoverage, + optimizePerformance: optimizePerformance, + ignore: ignore, + minCoverage: minCoverage, + excludeFromCoverage: excludeFromCoverage, + randomSeed: randomSeed, + forceAnsi: forceAnsi, + arguments: arguments, + stdout: stdout, + stderr: stderr, + buildGenerator: buildGenerator, + ); + } } diff --git a/lib/src/cli/flutter_cli.dart b/lib/src/cli/flutter_cli.dart index 41d1bde10..640d38ab5 100644 --- a/lib/src/cli/flutter_cli.dart +++ b/lib/src/cli/flutter_cli.dart @@ -58,18 +58,6 @@ class _ProcessSignalOverridesScope extends ProcessSignalOverrides { /// Thrown when `flutter pub get` 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; -} - class _CoverageMetrics { const _CoverageMetrics._({this.totalHits = 0, this.totalFound = 0}); @@ -103,19 +91,6 @@ class _CoverageMetrics { double get percentage => totalFound < 1 ? 0 : (totalHits / totalFound * 100); } -/// Type definition for the [flutterTest] command -/// from 'package:very_good_test_runner`. -typedef FlutterTestRunner = - Stream Function({ - List? arguments, - String? workingDirectory, - Map? environment, - bool runInShell, - }); - -/// A method which returns a [Future] given a [MasonBundle]. -typedef GeneratorBuilder = Future Function(MasonBundle); - /// Flutter CLI class Flutter { /// Determine whether flutter is installed. @@ -189,113 +164,26 @@ class Flutter { List? arguments, void Function(String)? stdout, void Function(String)? stderr, - FlutterTestRunner testRunner = flutterTest, GeneratorBuilder buildGenerator = MasonGenerator.fromBundle, }) async { - final initialCwd = cwd; - - return _runCommand( - cmd: (cwd) async { - final lcovPath = p.join(cwd, 'coverage', 'lcov.info'); - final lcovFile = File(lcovPath); - - if (collectCoverage && lcovFile.existsSync()) { - await lcovFile.delete(); - } - - void noop(String? _) {} - final target = DirectoryGeneratorTarget(Directory(p.normalize(cwd))); - final workingDirectory = target.dir.absolute.path; - final relativePath = p.relative(workingDirectory, from: initialCwd); - final path = relativePath == '.' - ? '.' - : '.${p.context.separator}$relativePath'; - - stdout?.call('Running "flutter test" in $path ...\n'); - - if (!Directory(p.join(target.dir.absolute.path, 'test')).existsSync()) { - stdout?.call('No test folder found in $path\n'); - return ExitCode.success.code; - } - - if (randomSeed != null) { - stdout?.call( - '''Shuffling test order with --test-randomize-ordering-seed=$randomSeed\n''', - ); - } - - if (optimizePerformance) { - final optimizationProgress = logger.progress('Optimizing tests'); - try { - final generator = await buildGenerator(testOptimizerBundle); - var vars = {'package-root': workingDirectory}; - await generator.hooks.preGen( - vars: vars, - onVarsChanged: (v) => vars = v, - workingDirectory: workingDirectory, - ); - await generator.generate( - target, - vars: vars, - fileConflictResolution: FileConflictResolution.overwrite, - ); - } finally { - optimizationProgress.complete(); - } - } - return _overrideAnsiOutput( - forceAnsi, - () => - _flutterTest( - cwd: cwd, - collectCoverage: collectCoverage, - testRunner: testRunner, - arguments: [ - ...?arguments, - if (randomSeed != null) ...[ - '--test-randomize-ordering-seed', - randomSeed, - ], - if (optimizePerformance) - p.join('test', _testOptimizerFileName), - ], - stdout: stdout ?? noop, - stderr: stderr ?? noop, - ).whenComplete(() async { - if (optimizePerformance) { - await _cleanupOptimizerFile(cwd); - } - - if (collectCoverage) { - assert( - lcovFile.existsSync(), - 'coverage/lcov.info must exist', - ); - } - - if (minCoverage != null) { - final records = await Parser.parse(lcovPath); - final coverageMetrics = _CoverageMetrics.fromLcovRecords( - records, - excludeFromCoverage, - ); - final coverage = coverageMetrics.percentage; - - if (coverage < minCoverage) throw MinCoverageNotMet(coverage); - } - }), - ); - }, + return TestCLIRunner.test( + logger: logger, + testType: TestRunType.flutter, cwd: cwd, recursive: recursive, + collectCoverage: collectCoverage, + optimizePerformance: optimizePerformance, ignore: ignore, + minCoverage: minCoverage, + excludeFromCoverage: excludeFromCoverage, + randomSeed: randomSeed, + forceAnsi: forceAnsi, + arguments: arguments, + stdout: stdout, + stderr: stderr, + buildGenerator: buildGenerator, ); } - - static T _overrideAnsiOutput(bool? enableAnsiOutput, T Function() body) => - enableAnsiOutput == null - ? body.call() - : overrideAnsiOutput(enableAnsiOutput, body); } /// Ensures all git dependencies are reachable for the pubspec @@ -361,228 +249,6 @@ Future> _runCommand({ return results; } -Future _flutterTest({ - required void Function(String) stdout, - required void Function(String) stderr, - String cwd = '.', - bool collectCoverage = false, - List? arguments, - FlutterTestRunner testRunner = flutterTest, -}) { - const clearLine = '\u001B[2K\r'; - - final completer = Completer(); - final suites = {}; - final groups = {}; - final tests = {}; - final failedTestErrorMessages = >{}; - final sigintWatch = - ProcessSignalOverrides.current?.sigintWatch ?? - ProcessSignal.sigint.watch(); - - var successCount = 0; - var skipCount = 0; - - String computeStats() { - final passingTests = successCount.formatSuccess(); - final failingTests = failedTestErrorMessages.values - .expand((e) => e) - .length - .formatFailure(); - final skippedTests = skipCount.formatSkipped(); - final result = [passingTests, failingTests, skippedTests] - ..removeWhere((element) => element.isEmpty); - return result.join(' '); - } - - final timerSubscription = - Stream.periodic( - const Duration(seconds: 1), - (computationCount) => computationCount, - ).listen((tick) { - if (completer.isCompleted) return; - final timeElapsed = Duration(seconds: tick).formatted(); - stdout('$clearLine$timeElapsed ...'); - }); - - late final StreamSubscription subscription; - late final StreamSubscription sigintWatchSubscription; - - sigintWatchSubscription = sigintWatch.listen((_) async { - await _cleanupOptimizerFile(cwd); - await subscription.cancel(); - await sigintWatchSubscription.cancel(); - return completer.complete(ExitCode.success.code); - }); - - subscription = - testRunner( - workingDirectory: cwd, - arguments: [if (collectCoverage) '--coverage', ...?arguments], - runInShell: true, - ).listen( - (event) { - if (event.shouldCancelTimer()) timerSubscription.cancel(); - if (event is SuiteTestEvent) suites[event.suite.id] = event.suite; - if (event is GroupTestEvent) groups[event.group.id] = event.group; - if (event is TestStartEvent) tests[event.test.id] = event.test; - - if (event is MessageTestEvent) { - if (event.message.startsWith('Skip:')) { - stdout('$clearLine${lightYellow.wrap(event.message)}\n'); - } else if (event.message.contains('EXCEPTION')) { - stderr('$clearLine${event.message}'); - } else { - stdout('$clearLine${event.message}\n'); - } - } - - if (event is ErrorTestEvent) { - stderr('$clearLine${event.error}'); - - if (event.stackTrace.trim().isNotEmpty) { - stderr('$clearLine${event.stackTrace}'); - } - - final test = tests[event.testID]!; - final suite = suites[test.suiteID]!; - final prefix = event.isFailure ? '[FAILED]' : '[ERROR]'; - - final optimizationApplied = _isOptimizationApplied(suite); - - var testPath = suite.path!; - var testName = test.name; - - // When there is a test error before any group is computed, it means - // that there is an error when compiling the test optimizer file. - if (optimizationApplied && groups.isNotEmpty) { - final topGroupName = _topGroupName(test, groups)!; - - testPath = testPath.replaceFirst( - _testOptimizerFileName, - topGroupName, - ); - - testName = testName.replaceFirst(topGroupName, '').trim(); - } - - final relativeTestPath = p.relative(testPath, from: cwd); - failedTestErrorMessages[relativeTestPath] = [ - ...failedTestErrorMessages[relativeTestPath] ?? [], - '$prefix $testName', - ]; - } - - if (event is TestDoneEvent) { - if (event.hidden) return; - - final test = tests[event.testID]!; - final suite = suites[test.suiteID]!; - final optimizationApplied = _isOptimizationApplied(suite); - - var testPath = suite.path!; - var testName = test.name; - - if (optimizationApplied) { - final firstGroupName = _topGroupName(test, groups) ?? ''; - testPath = testPath.replaceFirst( - _testOptimizerFileName, - firstGroupName, - ); - testName = testName.replaceFirst(firstGroupName, '').trim(); - } - - if (event.skipped) { - stdout( - '''$clearLine${lightYellow.wrap('$testName $testPath (SKIPPED)')}\n''', - ); - skipCount++; - } else if (event.result == TestResult.success) { - successCount++; - } else { - stderr('$clearLine$testName $testPath (FAILED)'); - } - - final timeElapsed = Duration(milliseconds: event.time).formatted(); - final stats = computeStats(); - final truncatedTestName = testName.toSingleLine().truncated( - _lineLength - (timeElapsed.length + stats.length + 2), - ); - stdout('''$clearLine$timeElapsed $stats: $truncatedTestName'''); - } - - if (event is DoneTestEvent) { - final timeElapsed = Duration(milliseconds: event.time).formatted(); - final stats = computeStats(); - final summary = event.success ?? false - ? lightGreen.wrap('All tests passed!')! - : lightRed.wrap('Some tests failed.')!; - - stdout( - '$clearLine${darkGray.wrap(timeElapsed)} $stats: $summary\n', - ); - - if (event.success != true) { - assert( - failedTestErrorMessages.isNotEmpty, - 'Invalid state: test event report as failed ' - 'but no failed tests were gathered', - ); - final title = styleBold.wrap('Failing Tests:'); - - final lines = StringBuffer('$clearLine$title\n'); - for (final testSuiteErrorMessages - in failedTestErrorMessages.entries) { - lines.writeln('$clearLine - ${testSuiteErrorMessages.key} '); - - for (final errorMessage in testSuiteErrorMessages.value) { - lines.writeln('$clearLine \t- $errorMessage'); - } - } - - stderr(lines.toString()); - } - } - - if (event is ExitTestEvent) { - if (completer.isCompleted) return; - subscription.cancel(); - sigintWatchSubscription.cancel(); - - completer.complete( - event.exitCode == ExitCode.success.code - ? ExitCode.success.code - : ExitCode.unavailable.code, - ); - } - }, - onError: (Object error, StackTrace stackTrace) { - stderr('$clearLine$error'); - stderr('$clearLine$stackTrace'); - }, - ); - - return completer.future; -} - -bool _isOptimizationApplied(TestSuite suite) => - suite.path?.contains(_testOptimizerFileName) ?? false; - -String? _topGroupName(Test test, Map groups) => test.groupIDs - .map((groupID) => groups[groupID]?.name) - .firstWhereOrNull((groupName) => groupName?.isNotEmpty ?? false); - -Future _cleanupOptimizerFile(String cwd) async => - File(p.join(cwd, 'test', _testOptimizerFileName)).delete().ignore(); - -final int _lineLength = () { - try { - return stdout.terminalColumns; - } on StdoutException { - return 80; - } -}(); - // The extension is intended to be unnamed, but it's not possible due to // an issue with Dart SDK 2.18.0. // diff --git a/lib/src/commands/test/templates/templates.dart b/lib/src/cli/templates/templates.dart similarity index 100% rename from lib/src/commands/test/templates/templates.dart rename to lib/src/cli/templates/templates.dart diff --git a/lib/src/commands/test/templates/test_optimizer_bundle.dart b/lib/src/cli/templates/test_optimizer_bundle.dart similarity index 100% rename from lib/src/commands/test/templates/test_optimizer_bundle.dart rename to lib/src/cli/templates/test_optimizer_bundle.dart diff --git a/lib/src/cli/test_cli_runner.dart b/lib/src/cli/test_cli_runner.dart new file mode 100644 index 000000000..29d654461 --- /dev/null +++ b/lib/src/cli/test_cli_runner.dart @@ -0,0 +1,441 @@ +part of 'cli.dart'; + +/// Type definition for the [flutterTest]/[dartTest] command +/// from 'package:very_good_test_runner`. +typedef VeryGoodTestRunner = + Stream Function({ + List? arguments, + String? workingDirectory, + Map? environment, + bool runInShell, + }); + +/// Which test runner to use for running tests. +enum TestRunType { + /// Run tests using `flutter test`. + flutter, + + /// Run tests using `dart test`. + dart, +} + +/// A method which returns a [Future] given a [MasonBundle]. +typedef GeneratorBuilder = Future Function(MasonBundle); + +/// {@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; +} + +/// A class to run test command from a CLI command, like `flutter` or `dart`. +/// +/// It abstracts common functionalities like the test optimization, coverage +/// collection, and concurrency management. +class TestCLIRunner { + /// Determines whether the user is targetting test files or not. + /// + /// The user can only target test files by using the `--` option terminator. + /// The additional options after the `--` are passed to the test runner which + /// allows the user to target specific test files or directories. + /// + /// The heuristics used to determine if the user is not targetting test files + /// are: + /// * No [rest] arguments are passed. + /// * All [rest] arguments are options (i.e. they do not start with `-`). + /// + /// See also: + /// * [What does -- mean in Shell?](https://www.cyberciti.biz/faq/what-does-double-dash-mean-in-ssh-command/) + static bool isTargettingTestFiles(List rest) { + if (rest.isEmpty) { + return false; + } + + return rest.where((arg) => !arg.startsWith('-')).isNotEmpty; + } + + /// Run tests (`flutter test`). + /// Returns a list of exit codes for each test process. + static Future> test({ + required Logger logger, + required TestRunType testType, + String cwd = '.', + bool recursive = false, + bool collectCoverage = false, + bool optimizePerformance = false, + Set ignore = const {}, + double? minCoverage, + String? excludeFromCoverage, + String? randomSeed, + bool? forceAnsi, + List? arguments, + void Function(String)? stdout, + void Function(String)? stderr, + GeneratorBuilder buildGenerator = MasonGenerator.fromBundle, + }) async { + final initialCwd = cwd; + + final testRunner = testType == TestRunType.flutter ? flutterTest : dartTest; + + return _runCommand( + cmd: (cwd) async { + final lcovPath = p.join(cwd, 'coverage', 'lcov.info'); + final lcovFile = File(lcovPath); + + if (collectCoverage && lcovFile.existsSync()) { + await lcovFile.delete(); + } + + void noop(String? _) {} + final target = DirectoryGeneratorTarget(Directory(p.normalize(cwd))); + final workingDirectory = target.dir.absolute.path; + final relativePath = p.relative(workingDirectory, from: initialCwd); + final path = relativePath == '.' + ? '.' + : '.${p.context.separator}$relativePath'; + + stdout?.call('Running "${testType.name} test" in $path ...\n'); + + if (!Directory(p.join(target.dir.absolute.path, 'test')).existsSync()) { + stdout?.call('No test folder found in $path\n'); + return ExitCode.success.code; + } + + if (randomSeed != null) { + stdout?.call( + '''Shuffling test order with --test-randomize-ordering-seed=$randomSeed\n''', + ); + } + + if (optimizePerformance) { + final optimizationProgress = logger.progress('Optimizing tests'); + try { + final generator = await buildGenerator(testOptimizerBundle); + var vars = {'package-root': workingDirectory}; + await generator.hooks.preGen( + vars: vars, + onVarsChanged: (v) => vars = v, + workingDirectory: workingDirectory, + ); + await generator.generate( + target, + vars: vars, + fileConflictResolution: FileConflictResolution.overwrite, + ); + } finally { + optimizationProgress.complete(); + } + } + return _overrideAnsiOutput( + forceAnsi, + () => + _testCommand( + cwd: cwd, + collectCoverage: collectCoverage, + testRunner: testRunner, + arguments: [ + ...?arguments, + if (randomSeed != null) ...[ + '--test-randomize-ordering-seed', + randomSeed, + ], + if (optimizePerformance) + p.join('test', _testOptimizerFileName), + ], + stdout: stdout ?? noop, + stderr: stderr ?? noop, + ).whenComplete(() async { + if (optimizePerformance) { + await _cleanupOptimizerFile(cwd); + } + + if (collectCoverage) { + assert( + lcovFile.existsSync(), + 'coverage/lcov.info must exist', + ); + } + + if (minCoverage != null) { + final records = await Parser.parse(lcovPath); + final coverageMetrics = _CoverageMetrics.fromLcovRecords( + records, + excludeFromCoverage, + ); + final coverage = coverageMetrics.percentage; + + if (coverage < minCoverage) throw MinCoverageNotMet(coverage); + } + }), + ); + }, + cwd: cwd, + recursive: recursive, + ignore: ignore, + ); + } + + static T _overrideAnsiOutput(bool? enableAnsiOutput, T Function() body) => + enableAnsiOutput == null + ? body.call() + : overrideAnsiOutput(enableAnsiOutput, body); + + /// Handles the [MinCoverageNotMet] exception by logging the error message + static void handleMinCoverageNotMet({ + required Logger logger, + required MinCoverageNotMet e, + double? minCoverage, + }) { + var decimalPlaces = 2; + + double round(double x) { + final b = pow(10, decimalPlaces); + return (x * b).roundToDouble() / b; + } + + if (e.coverage < minCoverage!) { + var rounded = round(e.coverage); + while (rounded == minCoverage) { + decimalPlaces++; + rounded = round(e.coverage); + } + } + + logger.err( + '''Expected coverage >= ${minCoverage.toStringAsFixed(decimalPlaces)}% but actual is ${e.coverage.toStringAsFixed(decimalPlaces)}%.''', + ); + } +} + +Future _testCommand({ + required void Function(String) stdout, + required void Function(String) stderr, + required VeryGoodTestRunner testRunner, + String cwd = '.', + bool collectCoverage = false, + List? arguments, +}) { + const clearLine = '\u001B[2K\r'; + + final completer = Completer(); + final suites = {}; + final groups = {}; + final tests = {}; + final failedTestErrorMessages = >{}; + final sigintWatch = + ProcessSignalOverrides.current?.sigintWatch ?? + ProcessSignal.sigint.watch(); + + var successCount = 0; + var skipCount = 0; + + String computeStats() { + final passingTests = successCount.formatSuccess(); + final failingTests = failedTestErrorMessages.values + .expand((e) => e) + .length + .formatFailure(); + final skippedTests = skipCount.formatSkipped(); + final result = [passingTests, failingTests, skippedTests] + ..removeWhere((element) => element.isEmpty); + return result.join(' '); + } + + final timerSubscription = + Stream.periodic( + const Duration(seconds: 1), + (computationCount) => computationCount, + ).listen((tick) { + if (completer.isCompleted) return; + final timeElapsed = Duration(seconds: tick).formatted(); + stdout('$clearLine$timeElapsed ...'); + }); + + late final StreamSubscription subscription; + late final StreamSubscription sigintWatchSubscription; + + sigintWatchSubscription = sigintWatch.listen((_) async { + await _cleanupOptimizerFile(cwd); + await subscription.cancel(); + await sigintWatchSubscription.cancel(); + return completer.complete(ExitCode.success.code); + }); + + subscription = + testRunner( + workingDirectory: cwd, + arguments: [if (collectCoverage) '--coverage', ...?arguments], + runInShell: true, + ).listen( + (event) { + if (event.shouldCancelTimer()) timerSubscription.cancel(); + if (event is SuiteTestEvent) suites[event.suite.id] = event.suite; + if (event is GroupTestEvent) groups[event.group.id] = event.group; + if (event is TestStartEvent) tests[event.test.id] = event.test; + + if (event is MessageTestEvent) { + if (event.message.startsWith('Skip:')) { + stdout('$clearLine${lightYellow.wrap(event.message)}\n'); + } else if (event.message.contains('EXCEPTION')) { + stderr('$clearLine${event.message}'); + } else { + stdout('$clearLine${event.message}\n'); + } + } + + if (event is ErrorTestEvent) { + stderr('$clearLine${event.error}'); + + if (event.stackTrace.trim().isNotEmpty) { + stderr('$clearLine${event.stackTrace}'); + } + + final test = tests[event.testID]!; + final suite = suites[test.suiteID]!; + final prefix = event.isFailure ? '[FAILED]' : '[ERROR]'; + + final optimizationApplied = _isOptimizationApplied(suite); + + var testPath = suite.path!; + var testName = test.name; + + // When there is a test error before any group is computed, it + // means that there is an error when compiling the test optimizer + // file. + if (optimizationApplied && groups.isNotEmpty) { + final topGroupName = _topGroupName(test, groups)!; + + testPath = testPath.replaceFirst( + _testOptimizerFileName, + topGroupName, + ); + + testName = testName.replaceFirst(topGroupName, '').trim(); + } + + final relativeTestPath = p.relative(testPath, from: cwd); + failedTestErrorMessages[relativeTestPath] = [ + ...failedTestErrorMessages[relativeTestPath] ?? [], + '$prefix $testName', + ]; + } + + if (event is TestDoneEvent) { + if (event.hidden) return; + + final test = tests[event.testID]!; + final suite = suites[test.suiteID]!; + final optimizationApplied = _isOptimizationApplied(suite); + + var testPath = suite.path!; + var testName = test.name; + + if (optimizationApplied) { + final firstGroupName = _topGroupName(test, groups) ?? ''; + testPath = testPath.replaceFirst( + _testOptimizerFileName, + firstGroupName, + ); + testName = testName.replaceFirst(firstGroupName, '').trim(); + } + + if (event.skipped) { + stdout( + '''$clearLine${lightYellow.wrap('$testName $testPath (SKIPPED)')}\n''', + ); + skipCount++; + } else if (event.result == TestResult.success) { + successCount++; + } else { + stderr('$clearLine$testName $testPath (FAILED)'); + } + + final timeElapsed = Duration( + milliseconds: event.time, + ).formatted(); + final stats = computeStats(); + final truncatedTestName = testName.toSingleLine().truncated( + _lineLength - (timeElapsed.length + stats.length + 2), + ); + stdout('''$clearLine$timeElapsed $stats: $truncatedTestName'''); + } + + if (event is DoneTestEvent) { + final timeElapsed = Duration( + milliseconds: event.time, + ).formatted(); + final stats = computeStats(); + final summary = event.success ?? false + ? lightGreen.wrap('All tests passed!')! + : lightRed.wrap('Some tests failed.')!; + + stdout( + '$clearLine${darkGray.wrap(timeElapsed)} $stats: $summary\n', + ); + + if (event.success != true) { + assert( + failedTestErrorMessages.isNotEmpty, + 'Invalid state: test event report as failed ' + 'but no failed tests were gathered', + ); + final title = styleBold.wrap('Failing Tests:'); + + final lines = StringBuffer('$clearLine$title\n'); + for (final testSuiteErrorMessages + in failedTestErrorMessages.entries) { + lines.writeln('$clearLine - ${testSuiteErrorMessages.key} '); + + for (final errorMessage in testSuiteErrorMessages.value) { + lines.writeln('$clearLine \t- $errorMessage'); + } + } + + stderr(lines.toString()); + } + } + + if (event is ExitTestEvent) { + if (completer.isCompleted) return; + subscription.cancel(); + sigintWatchSubscription.cancel(); + + completer.complete( + event.exitCode == ExitCode.success.code + ? ExitCode.success.code + : ExitCode.unavailable.code, + ); + } + }, + onError: (Object error, StackTrace stackTrace) { + stderr('$clearLine$error'); + stderr('$clearLine$stackTrace'); + }, + ); + + return completer.future; +} + +bool _isOptimizationApplied(TestSuite suite) => + suite.path?.contains(_testOptimizerFileName) ?? false; + +String? _topGroupName(Test test, Map groups) => test.groupIDs + .map((groupID) => groups[groupID]?.name) + .firstWhereOrNull((groupName) => groupName?.isNotEmpty ?? false); + +Future _cleanupOptimizerFile(String cwd) async => + File(p.join(cwd, 'test', _testOptimizerFileName)).delete().ignore(); + +final int _lineLength = () { + try { + return stdout.terminalColumns; + } on StdoutException { + return 80; + } +}(); diff --git a/lib/src/command_runner.dart b/lib/src/command_runner.dart index 0449385a7..8d034d6fd 100644 --- a/lib/src/command_runner.dart +++ b/lib/src/command_runner.dart @@ -36,6 +36,7 @@ class VeryGoodCommandRunner extends CompletionCommandRunner { addCommand(PackagesCommand(logger: _logger)); addCommand(TestCommand(logger: _logger)); addCommand(UpdateCommand(logger: _logger, pubUpdater: pubUpdater)); + addCommand(DartCommand(logger: _logger)); } /// Standard timeout duration for the CLI. diff --git a/lib/src/commands/commands.dart b/lib/src/commands/commands.dart index 52a83b524..86e5367a2 100644 --- a/lib/src/commands/commands.dart +++ b/lib/src/commands/commands.dart @@ -1,5 +1,6 @@ export 'create/commands/commands.dart'; export 'create/create.dart'; +export 'dart/dart.dart'; export 'packages/packages.dart'; export 'test/test.dart'; export 'update.dart'; diff --git a/lib/src/commands/dart/commands/commands.dart b/lib/src/commands/dart/commands/commands.dart new file mode 100644 index 000000000..4e303389c --- /dev/null +++ b/lib/src/commands/dart/commands/commands.dart @@ -0,0 +1 @@ +export 'dart_test_commands.dart'; diff --git a/lib/src/commands/dart/commands/dart_test_commands.dart b/lib/src/commands/dart/commands/dart_test_commands.dart new file mode 100644 index 000000000..81e446b2e --- /dev/null +++ b/lib/src/commands/dart/commands/dart_test_commands.dart @@ -0,0 +1,187 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:mason/mason.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as path; +import 'package:very_good_cli/src/cli/cli.dart'; + +/// Signature for the [Dart.installed] method. +typedef DartInstalledCommand = Future Function({required Logger logger}); + +/// Signature for the [Dart.test] method. +typedef DartTestCommandCall = + Future> Function({ + required Logger logger, + String cwd, + bool recursive, + bool collectCoverage, + bool optimizePerformance, + double? minCoverage, + String? excludeFromCoverage, + String? randomSeed, + bool? forceAnsi, + List? arguments, + void Function(String)? stdout, + void Function(String)? stderr, + }); + +/// {@template dart_test_command} +/// `very_good dart test` command for running dart tests. +/// {@endtemplate} +class DartTestCommand extends Command { + /// {@macro packages_command} + DartTestCommand({ + Logger? logger, + DartTestCommandCall? dartTest, + DartInstalledCommand? dartInstalled, + }) : _logger = logger ?? Logger(), + _dartTest = dartTest ?? Dart.test, + _dartInstalled = dartInstalled ?? Dart.installed { + argParser + ..addFlag( + 'coverage', + help: 'Whether to collect coverage information.', + negatable: false, + ) + ..addFlag( + 'recursive', + abbr: 'r', + help: 'Run tests recursively for all nested packages.', + negatable: false, + ) + ..addFlag( + 'optimization', + defaultsTo: true, + help: 'Whether to apply optimizations for test performance.', + ) + ..addOption( + 'concurrency', + abbr: 'j', + defaultsTo: '4', + help: 'The number of concurrent test suites run.', + ) + ..addOption( + 'tags', + abbr: 't', + help: 'Run only tests associated with the specified tags.', + ) + ..addOption( + 'exclude-coverage', + help: + 'A glob which will be used to exclude files that match from the ' + 'coverage.', + ) + ..addOption( + 'exclude-tags', + abbr: 'x', + help: 'Run only tests that do not have the specified tags.', + ) + ..addOption( + 'min-coverage', + help: 'Whether to enforce a minimum coverage percentage.', + ) + ..addOption( + 'test-randomize-ordering-seed', + help: + 'The seed to randomize the execution order of test cases ' + 'within test files.', + ) + ..addFlag( + 'force-ansi', + defaultsTo: null, + help: + 'Whether to force ansi output. If not specified, ' + 'it will maintain the default behavior based on stdout and stderr.', + negatable: false, + ); + } + + final Logger _logger; + final DartTestCommandCall _dartTest; + final DartInstalledCommand _dartInstalled; + + @override + String get description => 'Command for managing packages.'; + + @override + String get name => 'test'; + + /// [ArgResults] which can be overridden for testing. + @visibleForTesting + ArgResults? argResultOverrides; + + ArgResults get _argResults => argResultOverrides ?? argResults!; + + @override + Future run() async { + final targetPath = path.normalize(Directory.current.absolute.path); + final pubspec = File(path.join(targetPath, 'pubspec.yaml')); + final recursive = _argResults['recursive'] as bool; + + if (!recursive && !pubspec.existsSync()) { + _logger.err(''' +Could not find a pubspec.yaml in $targetPath. +This command should be run from the root of your Flutter project.'''); + return ExitCode.noInput.code; + } + + final concurrency = _argResults['concurrency'] as String; + final collectCoverage = _argResults['coverage'] as bool; + final minCoverage = double.tryParse( + _argResults['min-coverage'] as String? ?? '', + ); + final excludeTags = _argResults['exclude-tags'] as String?; + final tags = _argResults['tags'] as String?; + final isDartInstalled = await _dartInstalled(logger: _logger); + final excludeFromCoverage = _argResults['exclude-coverage'] as String?; + final randomOrderingSeed = + _argResults['test-randomize-ordering-seed'] as String?; + final randomSeed = randomOrderingSeed == 'random' + ? Random().nextInt(4294967295).toString() + : randomOrderingSeed; + final optimizePerformance = _argResults['optimization'] as bool; + final forceAnsi = _argResults['force-ansi'] as bool?; + final rest = _argResults.rest; + + if (isDartInstalled) { + try { + final results = await _dartTest( + optimizePerformance: + optimizePerformance && !TestCLIRunner.isTargettingTestFiles(rest), + recursive: recursive, + logger: _logger, + stdout: _logger.write, + stderr: _logger.err, + collectCoverage: collectCoverage || minCoverage != null, + minCoverage: minCoverage, + excludeFromCoverage: excludeFromCoverage, + randomSeed: randomSeed, + forceAnsi: forceAnsi, + arguments: [ + if (excludeTags != null) ...['-x', excludeTags], + if (tags != null) ...['-t', tags], + ...['-j', concurrency], + ...rest, + ], + ); + if (results.any((code) => code != ExitCode.success.code)) { + return ExitCode.unavailable.code; + } + } on MinCoverageNotMet catch (e) { + TestCLIRunner.handleMinCoverageNotMet( + logger: _logger, + minCoverage: minCoverage, + e: e, + ); + return ExitCode.unavailable.code; + } on Exception catch (error) { + _logger.err('$error'); + return ExitCode.unavailable.code; + } + } + return ExitCode.success.code; + } +} diff --git a/lib/src/commands/dart/dart.dart b/lib/src/commands/dart/dart.dart new file mode 100644 index 000000000..ed01f481b --- /dev/null +++ b/lib/src/commands/dart/dart.dart @@ -0,0 +1,19 @@ +import 'package:args/command_runner.dart'; +import 'package:mason/mason.dart'; +import 'package:very_good_cli/src/commands/dart/commands/commands.dart'; + +/// {@template dart_command} +/// `very_good dart` command for running dart processes. +/// {@endtemplate} +class DartCommand extends Command { + /// {@macro packages_command} + DartCommand({Logger? logger}) { + addSubcommand(DartTestCommand(logger: logger)); + } + + @override + String get description => 'Command for managing packages.'; + + @override + String get name => 'dart'; +} diff --git a/lib/src/commands/test/test.dart b/lib/src/commands/test/test.dart index de2655b65..42a0aacf4 100644 --- a/lib/src/commands/test/test.dart +++ b/lib/src/commands/test/test.dart @@ -186,7 +186,7 @@ This command should be run from the root of your Flutter project.'''); final results = await _flutterTest( optimizePerformance: optimizePerformance && - !_isTargettingTestFiles(rest) && + !TestCLIRunner.isTargettingTestFiles(rest) && !updateGoldens, recursive: recursive, logger: _logger, @@ -215,23 +215,10 @@ This command should be run from the root of your Flutter project.'''); return ExitCode.unavailable.code; } } on MinCoverageNotMet catch (e) { - var decimalPlaces = 2; - - double round(double x) { - final b = pow(10, decimalPlaces); - return (x * b).roundToDouble() / b; - } - - if (e.coverage < minCoverage!) { - var rounded = round(e.coverage); - while (rounded == minCoverage) { - decimalPlaces++; - rounded = round(e.coverage); - } - } - - _logger.err( - '''Expected coverage >= ${minCoverage.toStringAsFixed(decimalPlaces)}% but actual is ${e.coverage.toStringAsFixed(decimalPlaces)}%.''', + TestCLIRunner.handleMinCoverageNotMet( + logger: _logger, + minCoverage: minCoverage, + e: e, ); return ExitCode.unavailable.code; } on Exception catch (error) { @@ -242,24 +229,3 @@ This command should be run from the root of your Flutter project.'''); return ExitCode.success.code; } } - -/// Determines whether the user is targetting test files or not. -/// -/// The user can only target test files by using the `--` option terminator. -/// The additional options after the `--` are passed to the test runner which -/// allows the user to target specific test files or directories. -/// -/// The heuristics used to determine if the user is not targetting test files -/// are: -/// * No [rest] arguments are passed. -/// * All [rest] arguments are options (i.e. they do not start with `-`). -/// -/// See also: -/// * [What does -- mean in Shell?](https://www.cyberciti.biz/faq/what-does-double-dash-mean-in-ssh-command/) -bool _isTargettingTestFiles(List rest) { - if (rest.isEmpty) { - return false; - } - - return rest.where((arg) => !arg.startsWith('-')).isNotEmpty; -} From acef5cd3957775b0ba3e5b0368e626029242ae7e Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Mon, 11 Aug 2025 10:12:55 -0300 Subject: [PATCH 02/11] tests --- lib/src/cli/test_cli_runner.dart | 5 +- test/src/cli/flutter_cli_test.dart | 1113 ----------------------- test/src/cli/test_runner_cli_test.dart | 1150 ++++++++++++++++++++++++ 3 files changed, 1154 insertions(+), 1114 deletions(-) create mode 100644 test/src/cli/test_runner_cli_test.dart diff --git a/lib/src/cli/test_cli_runner.dart b/lib/src/cli/test_cli_runner.dart index 29d654461..d6437f1c5 100644 --- a/lib/src/cli/test_cli_runner.dart +++ b/lib/src/cli/test_cli_runner.dart @@ -78,10 +78,13 @@ class TestCLIRunner { void Function(String)? stdout, void Function(String)? stderr, GeneratorBuilder buildGenerator = MasonGenerator.fromBundle, + @visibleForTesting VeryGoodTestRunner? overrideTestRunner, }) async { final initialCwd = cwd; - final testRunner = testType == TestRunType.flutter ? flutterTest : dartTest; + final testRunner = + overrideTestRunner ?? + (testType == TestRunType.flutter ? flutterTest : dartTest); return _runCommand( cmd: (cwd) async { diff --git a/test/src/cli/flutter_cli_test.dart b/test/src/cli/flutter_cli_test.dart index 5e813bc79..be9796c83 100644 --- a/test/src/cli/flutter_cli_test.dart +++ b/test/src/cli/flutter_cli_test.dart @@ -6,13 +6,9 @@ import 'dart:async'; import 'package:mason/mason.dart'; import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; -import 'package:stack_trace/stack_trace.dart' as stack_trace; import 'package:test/test.dart'; import 'package:universal_io/io.dart'; import 'package:very_good_cli/src/cli/cli.dart'; -import 'package:very_good_test_runner/very_good_test_runner.dart'; - -import '../../fixtures/fixtures.dart'; const _pubspec = ''' name: example @@ -42,10 +38,6 @@ class _TestProcess { class _MockProcess extends Mock implements _TestProcess {} -class _MockMasonGenerator extends Mock implements MasonGenerator {} - -class _MockGeneratorHooks extends Mock implements GeneratorHooks {} - class _MockLogger extends Mock implements Logger {} class _MockProgress extends Mock implements Progress {} @@ -307,1110 +299,5 @@ void main() { ); }); }); - - group('.test', () { - late Progress progress; - late Logger logger; - late GeneratorHooks hooks; - late MasonGenerator generator; - late List stdoutLogs; - late List stderrLogs; - late List testRunnerArgs; - - FlutterTestRunner testRunner( - Stream stream, { - void Function()? onStart, - }) { - return ({ - arguments, - environment, - runInShell = false, - workingDirectory, - }) { - onStart?.call(); - if (arguments != null) testRunnerArgs.addAll(arguments); - return stream; - }; - } - - GeneratorBuilder generatorBuilder() => - (_) async => generator; - - setUp(() { - progress = _MockProgress(); - logger = _MockLogger(); - when(() => logger.progress(any())).thenReturn(progress); - hooks = _MockGeneratorHooks(); - generator = _MockMasonGenerator(); - when(() => generator.hooks).thenReturn(hooks); - when( - () => hooks.preGen( - vars: any(named: 'vars'), - onVarsChanged: any(named: 'onVarsChanged'), - workingDirectory: any(named: 'workingDirectory'), - ), - ).thenAnswer((_) async {}); - when( - () => generator.generate( - any(), - vars: any(named: 'vars'), - fileConflictResolution: any(named: 'fileConflictResolution'), - ), - ).thenAnswer((_) async => []); - testRunnerArgs = []; - stdoutLogs = []; - stderrLogs = []; - }); - - test('cleanup the .test_optimizer file when SIGINT is emitted', () async { - final streamController = StreamController(); - await ProcessSignalOverrides.runZoned( - () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - final updatedVars = { - 'package-root': tempDirectory.path, - 'foo': 'bar', - }; - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - when( - () => hooks.preGen( - vars: any(named: 'vars'), - onVarsChanged: any(named: 'onVarsChanged'), - workingDirectory: any(named: 'workingDirectory'), - ), - ).thenAnswer((invocation) async { - (invocation.namedArguments[#onVarsChanged] - as void Function( - Map vars, - )) - .call(updatedVars); - }); - ProcessSignalOverrides.current?.addSIGINT(); - await Flutter.test( - cwd: tempDirectory.path, - optimizePerformance: true, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - logger: logger, - buildGenerator: generatorBuilder(), - ); - final filePath = p.join( - tempDirectory.path, - 'test', - '.test_optimizer.dart', - ); - final testOptimizerFile = File(filePath); - expect(testOptimizerFile.existsSync(), isFalse); - }, - sigintStream: streamController.stream, - ); - }); - - test('throws when pubspec not found', () async { - await expectLater( - () => Flutter.test(cwd: Directory.systemTemp.path, logger: logger), - throwsA(isA()), - ); - }); - - test('completes when there is no test directory', () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - logger: logger, - ), - completion(equals([ExitCode.success.code])), - ); - expect( - stdoutLogs, - equals([ - 'Running "flutter test" in . ...\n', - 'No test folder found in .\n', - ]), - ); - }); - - test('runs tests and shows timer until tests start', () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - final controller = StreamController(); - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - - unawaited( - Flutter.test( - cwd: tempDirectory.path, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner(controller.stream), - logger: logger, - ), - ); - - await Future.delayed(const Duration(seconds: 1)); - - controller - ..add(const DoneTestEvent(success: true, time: 0)) - ..add( - const ExitTestEvent(exitCode: 0, time: 0), - ); - - await Future.delayed(Duration.zero); - - expect( - stdoutLogs, - equals([ - 'Running "flutter test" in . ...\n', - '\x1B[2K\r00:00 ...', - contains('All tests passed!'), - ]), - ); - }); - - test('runs tests (passing)', () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner( - Stream.fromIterable([ - ...passingJsonOutput.map(TestEvent.fromJson), - const ExitTestEvent(exitCode: 0, time: 0), - ]), - ), - logger: logger, - ), - completion(equals([ExitCode.success.code])), - ); - expect( - stdoutLogs, - equals([ - 'Running "flutter test" in . ...\n', - '\x1B[2K\r00:02 +1: CounterCubit initial state is 0', - '''\x1B[2K\r00:02 +2: CounterCubit emits [1] when increment is called''', - '''\x1B[2K\r00:02 +3: CounterCubit emits [-1] when decrement is called''', - '\x1B[2K\r00:03 +4: App renders CounterPage', - '\x1B[2K\r00:03 +5: CounterPage renders CounterView', - '\x1B[2K\r00:03 +6: CounterView renders current count', - '''\x1B[2K\r00:03 +7: CounterView calls increment when increment button is tapped''', - '''\x1B[2K\r00:03 +8: CounterView calls decrement when decrement button is tapped''', - '\x1B[2K\r00:04 +8: All tests passed!\n', - ]), - ); - expect(stderrLogs, isEmpty); - }); - - test('runs tests (passing) with forced ansi output', () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner( - Stream.fromIterable([ - ...passingJsonOutput.map(TestEvent.fromJson), - const ExitTestEvent(exitCode: 0, time: 0), - ]), - ), - logger: logger, - forceAnsi: true, - ), - completion(equals([ExitCode.success.code])), - ); - - expect( - stdoutLogs, - equals([ - 'Running "flutter test" in . ...\n', - '''\x1B[2K\r\x1B[90m00:02\x1B[0m \x1B[92m+1\x1B[0m: CounterCubit initial state is 0''', - '''\x1B[2K\r\x1B[90m00:02\x1B[0m \x1B[92m+2\x1B[0m: CounterCubit emits [1] when increment is called''', - '''\x1B[2K\r\x1B[90m00:02\x1B[0m \x1B[92m+3\x1B[0m: CounterCubit emits [-1] when decrement is called''', - '''\x1B[2K\r\x1B[90m00:03\x1B[0m \x1B[92m+4\x1B[0m: App renders CounterPage''', - '''\x1B[2K\r\x1B[90m00:03\x1B[0m \x1B[92m+5\x1B[0m: CounterPage renders CounterView''', - '''\x1B[2K\r\x1B[90m00:03\x1B[0m \x1B[92m+6\x1B[0m: CounterView renders current count''', - '''\x1B[2K\r\x1B[90m00:03\x1B[0m \x1B[92m+7\x1B[0m: ...rView calls increment when increment button is tapped''', - '''\x1B[2K\r\x1B[90m00:03\x1B[0m \x1B[92m+8\x1B[0m: ...rView calls decrement when decrement button is tapped''', - '''\x1B[2K\r\x1B[90m\x1B[90m00:04\x1B[0m\x1B[0m \x1B[92m+8\x1B[0m: \x1B[92mAll tests passed!\x1B[0m\n''', - ]), - ); - expect(stderrLogs, isEmpty); - }); - - test('runs tests (failing)', () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner( - Stream.fromIterable([ - ...failingJsonOutput( - tempDirectory.path, - ).map(TestEvent.fromJson), - const ExitTestEvent(exitCode: 1, time: 0), - ]), - ), - logger: logger, - ), - completion(equals([ExitCode.unavailable.code])), - ); - expect( - stdoutLogs, - equals([ - 'Running "flutter test" in . ...\n', - '\x1B[2K\r00:11 -1: CounterCubit initial state is 0', - '''\x1B[2K\r00:11 +1 -1: CounterCubit emits [1] when increment is called''', - '''\x1B[2K\r00:11 +2 -1: CounterCubit emits [-1] when decrement is called''', - '\x1B[2K\r00:11 +3 -1: App renders CounterPage', - '\x1B[2K\r00:12 +4 -1: CounterPage renders CounterView', - '\x1B[2K\r00:12 +5 -1: CounterView renders current count', - '''\x1B[2K\r00:12 +6 -1: CounterView calls increment when increment button is tapped''', - '''\x1B[2K\r00:12 +7 -1: CounterView calls decrement when decrement button is tapped''', - '\x1B[2K\r00:12 +7 -1: Some tests failed.\n', - ]), - ); - expect( - stderrLogs, - equals( - [ - '\x1B[2K\rExpected: <1>\n' - ' Actual: <0>\n', - '''\x1B[2K\rpackage:test_api expect\n''' - 'package:flutter_test/src/widget_tester.dart 455:16 expect\n' - 'test/counter/cubit/counter_cubit_test.dart 16:7 main..\n', - '\x1B[2K\rCounterCubit initial state is 0 ${tempDirectory.path}/test/counter/cubit/counter_cubit_test.dart (FAILED)', - '\x1B[2K\rFailing Tests:\n' - '\x1B[2K\r - test/counter/cubit/counter_cubit_test.dart \n' - '\x1B[2K\r \t- [FAILED] CounterCubit initial state is 0\n', - ], - ), - ); - }); - - test('runs tests (noisy)', () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner( - Stream.fromIterable([ - ...skipExceptionMessageJsonOutput( - tempDirectory.path, - ).map(TestEvent.fromJson), - const ExitTestEvent(exitCode: 0, time: 0), - ]), - ), - logger: logger, - ), - completion(equals([ExitCode.success.code])), - ); - expect( - stdoutLogs, - equals([ - 'Running "flutter test" in . ...\n', - '\x1B[2K\rSkip: currently failing (see issue 1234)\n', - '\x1B[2K\r(suite) ${tempDirectory.path}/test/counter/view/other_test.dart (SKIPPED)\n', - '\x1B[2K\r00:00 ~1: (suite)', - '\x1B[2K\rCounterCubit initial state is 0 ${tempDirectory.path}/test/counter/cubit/counter_cubit_test.dart (SKIPPED)\n', - '\x1B[2K\r00:02 ~2: CounterCubit initial state is 0', - '''\x1B[2K\r00:02 +1 ~2: CounterCubit emits [1] when increment is called''', - '''\x1B[2K\r00:02 +2 ~2: CounterCubit emits [-1] when decrement is called''', - '''\x1B[2K\r00:02 +3 ~2: ...a really long test name that should get truncated by very_good test''', - '\x1B[2K\r00:03 +3 -1 ~2: App renders CounterPage', - '\x1B[2K\rhello\n', - '\x1B[2K\r00:04 +4 -1 ~2: CounterPage renders CounterView', - '\x1B[2K\r00:04 +5 -1 ~2: CounterView renders current count', - '''\x1B[2K\r00:04 +6 -1 ~2: CounterView calls increment when increment button is tapped''', - '''\x1B[2K\r00:04 +7 -1 ~2: CounterView calls decrement when decrement button is tapped''', - '''\x1B[2K\r00:05 +8 -1 ~2: ...tiline test name that should be well processed by very_good test''', - '\x1B[2K\r00:05 +8 -1 ~2: Some tests failed.\n', - ]), - ); - expect( - stderrLogs, - equals([ - '''\x1B[2K\r══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════\n''' - 'The following _Exception was thrown running a test:\n' - 'Exception: oops\n' - '\n' - 'When the exception was thrown, this was the stack:\n' - '#0 main.. (file://${tempDirectory.path}/test/app/view/app_test.dart:15:7)\n' - '#1 main.. (file://${tempDirectory.path}/test/app/view/app_test.dart:14:40)\n' - '#2 testWidgets.. (package:flutter_test/src/widget_tester.dart:170:29)\n' - '\n' - '\n' - '(elided one frame from package:stack_trace)\n' - '\n' - 'The test description was:\n' - ' renders CounterPage\n' - '''════════════════════════════════════════════════════════════════════════════════════════════════════''', - '\x1B[2K\rTest failed. See exception logs above.\n' - 'The test description was: renders CounterPage', - '\x1B[2K\rApp renders CounterPage ${tempDirectory.path}/test/app/view/app_test.dart (FAILED)', - '\x1B[2K\rFailing Tests:\n' - '\x1B[2K\r - test/app/view/app_test.dart \n' - '\x1B[2K\r \t- [ERROR] App renders CounterPage\n', - ]), - ); - }); - - test('runs tests (error)', () async { - const exception = 'oops'; - - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - final controller = StreamController(); - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - controller - ..addError(exception) - ..add(const ExitTestEvent(exitCode: 1, time: 0)); - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner(controller.stream), - logger: logger, - ), - completion(equals([ExitCode.unavailable.code])), - ); - expect( - stderrLogs.first, - equals('\x1B[2K\r$exception'), - ); - expect( - stderrLogs[1], - startsWith('\x1B[2K\r'), - ); - }); - - test('runs tests (error w/stackTrace)', () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner( - Stream.fromIterable([ - SuiteTestEvent( - suite: TestSuite( - id: 4, - platform: 'vm', - path: '${tempDirectory.path}/test/app/view/app_test.dart', - ), - time: 0, - ), - GroupTestEvent( - group: TestGroup( - id: 10, - suiteID: 4, - name: 'CounterCubit', - metadata: TestMetadata( - skip: false, - ), - testCount: 1, - ), - time: 0, - ), - TestStartEvent( - test: Test( - id: 0, - name: 'CounterCubit emits [1] when increment is called', - suiteID: 4, - groupIDs: [10], - metadata: TestMetadata(skip: false), - ), - time: 0, - ), - ErrorTestEvent( - testID: 0, - error: 'error', - stackTrace: stack_trace.Trace.parse( - 'test/example_test.dart 4 main', - ).toString(), - isFailure: true, - time: 0, - ), - const DoneTestEvent(success: false, time: 0), - const ExitTestEvent(exitCode: 1, time: 0), - ]), - ), - logger: logger, - ), - completion(equals([ExitCode.unavailable.code])), - ); - expect( - stderrLogs, - equals([ - '\x1B[2K\rerror', - '\x1B[2K\rtest/example_test.dart 4 main\n', - '\x1B[2K\rFailing Tests:\n' - '\x1B[2K\r - test/app/view/app_test.dart \n' - '''\x1B[2K\r \t- [FAILED] CounterCubit emits [1] when increment is called\n''', - ]), - ); - }); - - test('runs tests (compilation error)', () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - - final testEventStream = Stream.fromIterable([ - ...compilationErrorJsonOutput( - tempDirectory.path, - ).map(TestEvent.fromJson), - const ExitTestEvent(exitCode: 1, time: 0), - ]); - - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner(testEventStream), - logger: logger, - ), - completion(equals([ExitCode.unavailable.code])), - ); - - expect( - stdoutLogs, - containsAllInOrder([ - '\x1B[2K\r00:00 -1: loading test/.test_optimizer.dart', - '\x1B[2K\r00:00 -1: Some tests failed.\n', - ]), - ); - expect( - stderrLogs, - containsAll([ - '\x1B[2K\rFailed to load "test/.test_optimizer.dart":\n' - "test/src/my_package_test.dart:8:18: Error: No named parameter with the name 'thing'.\n" - ' expect(Thing(thing: true), isNull);\n' - ' ^^^^^\n' - "lib/compilation_error.dart:2:9: Context: Found this candidate, but the arguments don't match.\n" - ' const Thing();\n' - ' ^^^^^', - ]), - ); - }); - - test('runs tests w/out logs', () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - testRunner: testRunner( - Stream.fromIterable( - [ - const DoneTestEvent(success: true, time: 0), - const ExitTestEvent(exitCode: 0, time: 0), - ], - ), - ), - logger: logger, - ), - completion(equals([ExitCode.success.code])), - ); - }); - - test('runs tests w/args', () async { - const arguments = ['-x', 'e2e', '-j', '1']; - - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - arguments: arguments, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner( - Stream.fromIterable( - [ - const DoneTestEvent(success: true, time: 0), - const ExitTestEvent(exitCode: 0, time: 0), - ], - ), - ), - logger: logger, - ), - completion(equals([ExitCode.success.code])), - ); - expect( - stdoutLogs, - equals([ - 'Running "flutter test" in . ...\n', - contains('All tests passed!'), - ]), - ); - expect(testRunnerArgs, equals(arguments)); - }); - - test('runs tests w/randomSeed', () async { - const seed = 'seed'; - - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - randomSeed: seed, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner( - Stream.fromIterable( - [ - const DoneTestEvent(success: true, time: 0), - const ExitTestEvent(exitCode: 0, time: 0), - ], - ), - ), - logger: logger, - ), - completion(equals([ExitCode.success.code])), - ); - expect( - stdoutLogs, - equals([ - 'Running "flutter test" in . ...\n', - 'Shuffling test order with --test-randomize-ordering-seed=$seed\n', - contains('All tests passed!'), - ]), - ); - expect( - testRunnerArgs, - equals(['--test-randomize-ordering-seed', seed]), - ); - }); - - test('runs tests w/coverage', () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - final lcovFile = File( - p.join(tempDirectory.path, 'coverage', 'lcov.info'), - ); - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - lcovFile.createSync(recursive: true); - - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - collectCoverage: true, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner( - Stream.fromIterable( - [ - const DoneTestEvent(success: true, time: 0), - const ExitTestEvent(exitCode: 0, time: 0), - ], - ), - onStart: () { - expect(lcovFile.existsSync(), isFalse); - lcovFile.createSync(recursive: true); - }, - ), - logger: logger, - ), - completion(equals([ExitCode.success.code])), - ); - expect( - stdoutLogs, - equals([ - 'Running "flutter test" in . ...\n', - contains('All tests passed!'), - ]), - ); - expect(testRunnerArgs, equals(['--coverage'])); - }); - - test('runs tests w/coverage + min-coverage 100 (pass)', () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - collectCoverage: true, - minCoverage: 100, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner( - Stream.fromIterable( - [ - const DoneTestEvent(success: true, time: 0), - const ExitTestEvent(exitCode: 0, time: 0), - ], - ), - onStart: () { - File(p.join(tempDirectory.path, 'coverage', 'lcov.info')) - ..createSync(recursive: true) - ..writeAsStringSync(lcov100); - }, - ), - logger: logger, - ), - completion(equals([ExitCode.success.code])), - ); - expect( - stdoutLogs, - equals([ - 'Running "flutter test" in . ...\n', - contains('All tests passed!'), - ]), - ); - expect(testRunnerArgs, equals(['--coverage'])); - }); - - test('runs tests w/coverage + min-coverage 100 (fail)', () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - - await expectLater( - () => Flutter.test( - cwd: tempDirectory.path, - collectCoverage: true, - minCoverage: 100, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner( - Stream.fromIterable( - [ - const DoneTestEvent(success: true, time: 0), - const ExitTestEvent(exitCode: 0, time: 0), - ], - ), - onStart: () { - File(p.join(tempDirectory.path, 'coverage', 'lcov.info')) - ..createSync(recursive: true) - ..writeAsStringSync(lcov95); - }, - ), - logger: logger, - ), - throwsA( - isA().having( - (e) => e.coverage, - 'coverage', - 95.0, - ), - ), - ); - expect( - stdoutLogs, - equals([ - 'Running "flutter test" in . ...\n', - contains('All tests passed!'), - ]), - ); - expect(stderrLogs, isEmpty); - expect(testRunnerArgs, equals(['--coverage'])); - }); - - test( - 'runs tests w/coverage + min-coverage 100 + exclude coverage (pass)', - () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - collectCoverage: true, - minCoverage: 100, - excludeFromCoverage: - '/bloc/packages/bloc/lib/src/bloc_observer.dart', - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner( - Stream.fromIterable( - [ - const DoneTestEvent(success: true, time: 0), - const ExitTestEvent(exitCode: 0, time: 0), - ], - ), - onStart: () { - File(p.join(tempDirectory.path, 'coverage', 'lcov.info')) - ..createSync(recursive: true) - ..writeAsStringSync(lcov95); - }, - ), - logger: logger, - ), - completion(equals([ExitCode.success.code])), - ); - expect( - stdoutLogs, - equals([ - 'Running "flutter test" in . ...\n', - contains('All tests passed!'), - ]), - ); - expect(stderrLogs, isEmpty); - expect(testRunnerArgs, equals(['--coverage'])); - }, - ); - - test( - 'runs tests w/coverage + min-coverage 100 + recursive (pass)', - () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - - final tempNestedDirectory = Directory( - p.join(tempDirectory.path, 'test'), - )..createSync(); - File(p.join(tempNestedDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempNestedDirectory.path, 'test')).createSync(); - - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - collectCoverage: true, - minCoverage: 100, - recursive: true, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner( - Stream.fromIterable( - [ - const DoneTestEvent(success: true, time: 0), - const ExitTestEvent(exitCode: 0, time: 0), - ], - ), - onStart: () { - File(p.join(tempDirectory.path, 'coverage', 'lcov.info')) - ..createSync(recursive: true) - ..writeAsStringSync(lcov100); - File( - p.join(tempNestedDirectory.path, 'coverage', 'lcov.info'), - ) - ..createSync(recursive: true) - ..writeAsStringSync(lcov100); - }, - ), - logger: logger, - ), - completion(equals([ExitCode.success.code, ExitCode.success.code])), - ); - - final nestedRelativePath = p.relative( - tempNestedDirectory.path, - from: tempDirectory.path, - ); - final relativePathPrefix = '.${p.context.separator}'; - - expect( - stdoutLogs, - unorderedEquals([ - '''Running "flutter test" in $relativePathPrefix$nestedRelativePath ...\n''', - contains('All tests passed!'), - 'Running "flutter test" in . ...\n', - contains('All tests passed!'), - ]), - ); - expect(testRunnerArgs, equals(['--coverage', '--coverage'])); - }, - ); - - test( - 'runs tests w/coverage + min-coverage 100 + recursive (fail)', - () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - - final tempNestedDirectory = Directory( - p.join(tempDirectory.path, 'test'), - )..createSync(); - File(p.join(tempNestedDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempNestedDirectory.path, 'test')).createSync(); - - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - collectCoverage: true, - minCoverage: 100, - recursive: true, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner( - Stream.fromIterable( - [ - const DoneTestEvent(success: true, time: 0), - const ExitTestEvent(exitCode: 0, time: 0), - ], - ), - onStart: () { - File(p.join(tempDirectory.path, 'coverage', 'lcov.info')) - ..createSync(recursive: true) - ..writeAsStringSync(lcov100); - File( - p.join(tempNestedDirectory.path, 'coverage', 'lcov.info'), - ) - ..createSync(recursive: true) - ..writeAsStringSync(lcov95); - }, - ), - logger: logger, - ), - throwsA( - isA().having( - (e) => e.coverage, - 'coverage', - 95.0, - ), - ), - ); - - final nestedRelativePath = p.relative( - tempNestedDirectory.path, - from: tempDirectory.path, - ); - final relativePathPrefix = '.${p.context.separator}'; - - expect( - stdoutLogs, - unorderedEquals([ - 'Running "flutter test" in ' - '. ...\n', - contains('All tests passed!'), - '''Running "flutter test" in $relativePathPrefix$nestedRelativePath ...\n''', - contains('All tests passed!'), - ]), - ); - expect(stderrLogs, isEmpty); - expect(testRunnerArgs, equals(['--coverage', '--coverage'])); - }, - ); - - test('runs tests w/optimizations (passing)', () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - final originalVars = { - 'package-root': tempDirectory.path, - }; - final updatedVars = { - 'package-root': tempDirectory.path, - 'foo': 'bar', - }; - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - when( - () => hooks.preGen( - vars: any(named: 'vars'), - onVarsChanged: any(named: 'onVarsChanged'), - workingDirectory: any(named: 'workingDirectory'), - ), - ).thenAnswer((invocation) async { - (invocation.namedArguments[#onVarsChanged] - as void Function( - Map vars, - )) - .call(updatedVars); - }); - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - optimizePerformance: true, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - logger: logger, - testRunner: testRunner( - Stream.fromIterable( - [ - const DoneTestEvent(success: true, time: 0), - const ExitTestEvent(exitCode: 0, time: 0), - ], - ), - ), - buildGenerator: generatorBuilder(), - ), - completion(equals([ExitCode.success.code])), - ); - expect( - stdoutLogs, - equals([ - 'Running "flutter test" in . ...\n', - contains('All tests passed!'), - ]), - ); - expect( - testRunnerArgs, - equals([p.join('test', '.test_optimizer.dart')]), - ); - verify(() => logger.progress('Optimizing tests')).called(1); - verify( - () => hooks.preGen( - vars: originalVars, - onVarsChanged: any(named: 'onVarsChanged'), - workingDirectory: tempDirectory.path, - ), - ).called(1); - verify( - () => generator.generate( - any(), - vars: updatedVars, - fileConflictResolution: FileConflictResolution.overwrite, - ), - ).called(1); - verify(() => progress.complete()).called(1); - }); - - test('runs tests w/optimizations (failing)', () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - testRunner: testRunner( - Stream.fromIterable([ - SuiteTestEvent( - suite: TestSuite( - id: 4, - platform: 'vm', - path: '${tempDirectory.path}/test/.test_optimizer.dart', - ), - time: 0, - ), - GroupTestEvent( - group: TestGroup( - id: 10, - suiteID: 4, - name: 'app/view/app_test.dart', - metadata: TestMetadata( - skip: false, - ), - testCount: 1, - ), - time: 0, - ), - GroupTestEvent( - group: TestGroup( - id: 99, - suiteID: 4, - name: 'app/view/app_test.dart CounterCubit', - metadata: TestMetadata( - skip: false, - ), - testCount: 1, - ), - time: 0, - ), - TestStartEvent( - test: Test( - id: 0, - name: - 'app/view/app_test.dart CounterCubit emits [1] when increment is called', - suiteID: 4, - groupIDs: [10, 99], - metadata: TestMetadata(skip: false), - ), - time: 0, - ), - ErrorTestEvent( - testID: 0, - error: 'error', - stackTrace: stack_trace.Trace.parse( - 'test/example_test.dart 4 main', - ).toString(), - isFailure: true, - time: 0, - ), - const DoneTestEvent(success: false, time: 0), - const ExitTestEvent(exitCode: 1, time: 0), - ]), - ), - logger: logger, - ), - completion(equals([ExitCode.unavailable.code])), - ); - expect( - stderrLogs, - equals([ - '\x1B[2K\rerror', - '\x1B[2K\rtest/example_test.dart 4 main\n', - '\x1B[2K\rFailing Tests:\n' - '\x1B[2K\r - test/app/view/app_test.dart \n' - '''\x1B[2K\r \t- [FAILED] CounterCubit emits [1] when increment is called\n''', - ]), - ); - }); - }); }); } diff --git a/test/src/cli/test_runner_cli_test.dart b/test/src/cli/test_runner_cli_test.dart new file mode 100644 index 000000000..e974100c2 --- /dev/null +++ b/test/src/cli/test_runner_cli_test.dart @@ -0,0 +1,1150 @@ +// Expected usage of the plugin will need to be adjacent strings due to format. +// ignore_for_file: no_adjacent_strings_in_list, lines_longer_than_80_chars + +import 'dart:async'; +import 'dart:io'; + +import 'package:mason/mason.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; +import 'package:stack_trace/stack_trace.dart' as stack_trace; +import 'package:test/test.dart'; +import 'package:very_good_cli/src/cli/cli.dart'; +import 'package:very_good_test_runner/very_good_test_runner.dart'; +import '../../fixtures/fixtures.dart'; + +class _MockMasonGenerator extends Mock implements MasonGenerator {} + +class _MockGeneratorHooks extends Mock implements GeneratorHooks {} + +class _MockProgress extends Mock implements Progress {} + +class _MockLogger extends Mock implements Logger {} + +void main() { + group('TestCLIRunner', () { + group('.test', () { + late Progress progress; + late Logger logger; + late GeneratorHooks hooks; + late MasonGenerator generator; + late List stdoutLogs; + late List stderrLogs; + late List testRunnerArgs; + + VeryGoodTestRunner testRunner( + Stream stream, { + void Function()? onStart, + }) { + return ({ + arguments, + environment, + runInShell = false, + workingDirectory, + }) { + onStart?.call(); + if (arguments != null) testRunnerArgs.addAll(arguments); + return stream; + }; + } + + GeneratorBuilder generatorBuilder() => + (_) async => generator; + + setUp(() { + progress = _MockProgress(); + logger = _MockLogger(); + when(() => logger.progress(any())).thenReturn(progress); + hooks = _MockGeneratorHooks(); + generator = _MockMasonGenerator(); + when(() => generator.hooks).thenReturn(hooks); + when( + () => hooks.preGen( + vars: any(named: 'vars'), + onVarsChanged: any(named: 'onVarsChanged'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((_) async {}); + when( + () => generator.generate( + any(), + vars: any(named: 'vars'), + fileConflictResolution: any(named: 'fileConflictResolution'), + ), + ).thenAnswer((_) async => []); + testRunnerArgs = []; + stdoutLogs = []; + stderrLogs = []; + }); + + test('cleanup the .test_optimizer file when SIGINT is emitted', () async { + final streamController = StreamController(); + await ProcessSignalOverrides.runZoned( + () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final updatedVars = { + 'package-root': tempDirectory.path, + 'foo': 'bar', + }; + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + when( + () => hooks.preGen( + vars: any(named: 'vars'), + onVarsChanged: any(named: 'onVarsChanged'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((invocation) async { + (invocation.namedArguments[#onVarsChanged] + as void Function( + Map vars, + )) + .call(updatedVars); + }); + ProcessSignalOverrides.current?.addSIGINT(); + await Flutter.test( + cwd: tempDirectory.path, + optimizePerformance: true, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + logger: logger, + buildGenerator: generatorBuilder(), + ); + final filePath = p.join( + tempDirectory.path, + 'test', + '.test_optimizer.dart', + ); + final testOptimizerFile = File(filePath); + expect(testOptimizerFile.existsSync(), isFalse); + }, + sigintStream: streamController.stream, + ); + }); + + test('throws when pubspec not found', () async { + await expectLater( + () => Flutter.test(cwd: Directory.systemTemp.path, logger: logger), + throwsA(isA()), + ); + }); + + test('completes when there is no test directory', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + await expectLater( + Flutter.test( + cwd: tempDirectory.path, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + logger: logger, + ), + completion(equals([ExitCode.success.code])), + ); + expect( + stdoutLogs, + equals([ + 'Running "flutter test" in . ...\n', + 'No test folder found in .\n', + ]), + ); + }); + + test('runs tests and shows timer until tests start', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final controller = StreamController(); + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + + unawaited( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner(controller.stream), + logger: logger, + ), + ); + + await Future.delayed(const Duration(seconds: 1)); + + controller + ..add(const DoneTestEvent(success: true, time: 0)) + ..add( + const ExitTestEvent(exitCode: 0, time: 0), + ); + + await Future.delayed(Duration.zero); + + expect( + stdoutLogs, + equals([ + 'Running "flutter test" in . ...\n', + '\x1B[2K\r00:00 ...', + contains('All tests passed!'), + ]), + ); + }); + + test('runs tests (passing)', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable([ + ...passingJsonOutput.map(TestEvent.fromJson), + const ExitTestEvent(exitCode: 0, time: 0), + ]), + ), + logger: logger, + ), + completion(equals([ExitCode.success.code])), + ); + expect( + stdoutLogs, + equals([ + 'Running "flutter test" in . ...\n', + '\x1B[2K\r00:02 +1: CounterCubit initial state is 0', + '''\x1B[2K\r00:02 +2: CounterCubit emits [1] when increment is called''', + '''\x1B[2K\r00:02 +3: CounterCubit emits [-1] when decrement is called''', + '\x1B[2K\r00:03 +4: App renders CounterPage', + '\x1B[2K\r00:03 +5: CounterPage renders CounterView', + '\x1B[2K\r00:03 +6: CounterView renders current count', + '''\x1B[2K\r00:03 +7: CounterView calls increment when increment button is tapped''', + '''\x1B[2K\r00:03 +8: CounterView calls decrement when decrement button is tapped''', + '\x1B[2K\r00:04 +8: All tests passed!\n', + ]), + ); + expect(stderrLogs, isEmpty); + }); + + test('runs tests (passing) with forced ansi output', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable([ + ...passingJsonOutput.map(TestEvent.fromJson), + const ExitTestEvent(exitCode: 0, time: 0), + ]), + ), + logger: logger, + forceAnsi: true, + ), + completion(equals([ExitCode.success.code])), + ); + + expect( + stdoutLogs, + equals([ + 'Running "flutter test" in . ...\n', + '''\x1B[2K\r\x1B[90m00:02\x1B[0m \x1B[92m+1\x1B[0m: CounterCubit initial state is 0''', + '''\x1B[2K\r\x1B[90m00:02\x1B[0m \x1B[92m+2\x1B[0m: CounterCubit emits [1] when increment is called''', + '''\x1B[2K\r\x1B[90m00:02\x1B[0m \x1B[92m+3\x1B[0m: CounterCubit emits [-1] when decrement is called''', + '''\x1B[2K\r\x1B[90m00:03\x1B[0m \x1B[92m+4\x1B[0m: App renders CounterPage''', + '''\x1B[2K\r\x1B[90m00:03\x1B[0m \x1B[92m+5\x1B[0m: CounterPage renders CounterView''', + '''\x1B[2K\r\x1B[90m00:03\x1B[0m \x1B[92m+6\x1B[0m: CounterView renders current count''', + '''\x1B[2K\r\x1B[90m00:03\x1B[0m \x1B[92m+7\x1B[0m: ...rView calls increment when increment button is tapped''', + '''\x1B[2K\r\x1B[90m00:03\x1B[0m \x1B[92m+8\x1B[0m: ...rView calls decrement when decrement button is tapped''', + '''\x1B[2K\r\x1B[90m\x1B[90m00:04\x1B[0m\x1B[0m \x1B[92m+8\x1B[0m: \x1B[92mAll tests passed!\x1B[0m\n''', + ]), + ); + expect(stderrLogs, isEmpty); + }); + + test('runs tests (failing)', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable([ + ...failingJsonOutput( + tempDirectory.path, + ).map(TestEvent.fromJson), + const ExitTestEvent(exitCode: 1, time: 0), + ]), + ), + logger: logger, + ), + completion(equals([ExitCode.unavailable.code])), + ); + expect( + stdoutLogs, + equals([ + 'Running "flutter test" in . ...\n', + '\x1B[2K\r00:11 -1: CounterCubit initial state is 0', + '''\x1B[2K\r00:11 +1 -1: CounterCubit emits [1] when increment is called''', + '''\x1B[2K\r00:11 +2 -1: CounterCubit emits [-1] when decrement is called''', + '\x1B[2K\r00:11 +3 -1: App renders CounterPage', + '\x1B[2K\r00:12 +4 -1: CounterPage renders CounterView', + '\x1B[2K\r00:12 +5 -1: CounterView renders current count', + '''\x1B[2K\r00:12 +6 -1: CounterView calls increment when increment button is tapped''', + '''\x1B[2K\r00:12 +7 -1: CounterView calls decrement when decrement button is tapped''', + '\x1B[2K\r00:12 +7 -1: Some tests failed.\n', + ]), + ); + expect( + stderrLogs, + equals( + [ + '\x1B[2K\rExpected: <1>\n' + ' Actual: <0>\n', + '''\x1B[2K\rpackage:test_api expect\n''' + 'package:flutter_test/src/widget_tester.dart 455:16 expect\n' + 'test/counter/cubit/counter_cubit_test.dart 16:7 main..\n', + '\x1B[2K\rCounterCubit initial state is 0 ${tempDirectory.path}/test/counter/cubit/counter_cubit_test.dart (FAILED)', + '\x1B[2K\rFailing Tests:\n' + '\x1B[2K\r - test/counter/cubit/counter_cubit_test.dart \n' + '\x1B[2K\r \t- [FAILED] CounterCubit initial state is 0\n', + ], + ), + ); + }); + + test('runs tests (noisy)', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable([ + ...skipExceptionMessageJsonOutput( + tempDirectory.path, + ).map(TestEvent.fromJson), + const ExitTestEvent(exitCode: 0, time: 0), + ]), + ), + logger: logger, + ), + completion(equals([ExitCode.success.code])), + ); + expect( + stdoutLogs, + equals([ + 'Running "flutter test" in . ...\n', + '\x1B[2K\rSkip: currently failing (see issue 1234)\n', + '\x1B[2K\r(suite) ${tempDirectory.path}/test/counter/view/other_test.dart (SKIPPED)\n', + '\x1B[2K\r00:00 ~1: (suite)', + '\x1B[2K\rCounterCubit initial state is 0 ${tempDirectory.path}/test/counter/cubit/counter_cubit_test.dart (SKIPPED)\n', + '\x1B[2K\r00:02 ~2: CounterCubit initial state is 0', + '''\x1B[2K\r00:02 +1 ~2: CounterCubit emits [1] when increment is called''', + '''\x1B[2K\r00:02 +2 ~2: CounterCubit emits [-1] when decrement is called''', + '''\x1B[2K\r00:02 +3 ~2: ...a really long test name that should get truncated by very_good test''', + '\x1B[2K\r00:03 +3 -1 ~2: App renders CounterPage', + '\x1B[2K\rhello\n', + '\x1B[2K\r00:04 +4 -1 ~2: CounterPage renders CounterView', + '\x1B[2K\r00:04 +5 -1 ~2: CounterView renders current count', + '''\x1B[2K\r00:04 +6 -1 ~2: CounterView calls increment when increment button is tapped''', + '''\x1B[2K\r00:04 +7 -1 ~2: CounterView calls decrement when decrement button is tapped''', + '''\x1B[2K\r00:05 +8 -1 ~2: ...tiline test name that should be well processed by very_good test''', + '\x1B[2K\r00:05 +8 -1 ~2: Some tests failed.\n', + ]), + ); + expect( + stderrLogs, + equals([ + '''\x1B[2K\r══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════\n''' + 'The following _Exception was thrown running a test:\n' + 'Exception: oops\n' + '\n' + 'When the exception was thrown, this was the stack:\n' + '#0 main.. (file://${tempDirectory.path}/test/app/view/app_test.dart:15:7)\n' + '#1 main.. (file://${tempDirectory.path}/test/app/view/app_test.dart:14:40)\n' + '#2 testWidgets.. (package:flutter_test/src/widget_tester.dart:170:29)\n' + '\n' + '\n' + '(elided one frame from package:stack_trace)\n' + '\n' + 'The test description was:\n' + ' renders CounterPage\n' + '''════════════════════════════════════════════════════════════════════════════════════════════════════''', + '\x1B[2K\rTest failed. See exception logs above.\n' + 'The test description was: renders CounterPage', + '\x1B[2K\rApp renders CounterPage ${tempDirectory.path}/test/app/view/app_test.dart (FAILED)', + '\x1B[2K\rFailing Tests:\n' + '\x1B[2K\r - test/app/view/app_test.dart \n' + '\x1B[2K\r \t- [ERROR] App renders CounterPage\n', + ]), + ); + }); + + test('runs tests (error)', () async { + const exception = 'oops'; + + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final controller = StreamController(); + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + controller + ..addError(exception) + ..add(const ExitTestEvent(exitCode: 1, time: 0)); + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner(controller.stream), + logger: logger, + ), + completion(equals([ExitCode.unavailable.code])), + ); + expect( + stderrLogs.first, + equals('\x1B[2K\r$exception'), + ); + expect( + stderrLogs[1], + startsWith('\x1B[2K\r'), + ); + }); + + test('runs tests (error w/stackTrace)', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable([ + SuiteTestEvent( + suite: TestSuite( + id: 4, + platform: 'vm', + path: '${tempDirectory.path}/test/app/view/app_test.dart', + ), + time: 0, + ), + GroupTestEvent( + group: TestGroup( + id: 10, + suiteID: 4, + name: 'CounterCubit', + metadata: TestMetadata( + skip: false, + ), + testCount: 1, + ), + time: 0, + ), + TestStartEvent( + test: Test( + id: 0, + name: 'CounterCubit emits [1] when increment is called', + suiteID: 4, + groupIDs: [10], + metadata: TestMetadata(skip: false), + ), + time: 0, + ), + ErrorTestEvent( + testID: 0, + error: 'error', + stackTrace: stack_trace.Trace.parse( + 'test/example_test.dart 4 main', + ).toString(), + isFailure: true, + time: 0, + ), + const DoneTestEvent(success: false, time: 0), + const ExitTestEvent(exitCode: 1, time: 0), + ]), + ), + logger: logger, + ), + completion(equals([ExitCode.unavailable.code])), + ); + expect( + stderrLogs, + equals([ + '\x1B[2K\rerror', + '\x1B[2K\rtest/example_test.dart 4 main\n', + '\x1B[2K\rFailing Tests:\n' + '\x1B[2K\r - test/app/view/app_test.dart \n' + '''\x1B[2K\r \t- [FAILED] CounterCubit emits [1] when increment is called\n''', + ]), + ); + }); + + test('runs tests (compilation error)', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + + final testEventStream = Stream.fromIterable([ + ...compilationErrorJsonOutput( + tempDirectory.path, + ).map(TestEvent.fromJson), + const ExitTestEvent(exitCode: 1, time: 0), + ]); + + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner(testEventStream), + logger: logger, + ), + completion(equals([ExitCode.unavailable.code])), + ); + + expect( + stdoutLogs, + containsAllInOrder([ + '\x1B[2K\r00:00 -1: loading test/.test_optimizer.dart', + '\x1B[2K\r00:00 -1: Some tests failed.\n', + ]), + ); + expect( + stderrLogs, + containsAll([ + '\x1B[2K\rFailed to load "test/.test_optimizer.dart":\n' + "test/src/my_package_test.dart:8:18: Error: No named parameter with the name 'thing'.\n" + ' expect(Thing(thing: true), isNull);\n' + ' ^^^^^\n' + "lib/compilation_error.dart:2:9: Context: Found this candidate, but the arguments don't match.\n" + ' const Thing();\n' + ' ^^^^^', + ]), + ); + }); + + test('runs tests w/out logs', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + overrideTestRunner: testRunner( + Stream.fromIterable( + [ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ], + ), + ), + logger: logger, + ), + completion(equals([ExitCode.success.code])), + ); + }); + + test('runs tests w/args', () async { + const arguments = ['-x', 'e2e', '-j', '1']; + + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + arguments: arguments, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable( + [ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ], + ), + ), + logger: logger, + ), + completion(equals([ExitCode.success.code])), + ); + expect( + stdoutLogs, + equals([ + 'Running "flutter test" in . ...\n', + contains('All tests passed!'), + ]), + ); + expect(testRunnerArgs, equals(arguments)); + }); + + test('runs tests w/randomSeed', () async { + const seed = 'seed'; + + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + randomSeed: seed, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable( + [ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ], + ), + ), + logger: logger, + ), + completion(equals([ExitCode.success.code])), + ); + expect( + stdoutLogs, + equals([ + 'Running "flutter test" in . ...\n', + 'Shuffling test order with --test-randomize-ordering-seed=$seed\n', + contains('All tests passed!'), + ]), + ); + expect( + testRunnerArgs, + equals(['--test-randomize-ordering-seed', seed]), + ); + }); + + test('runs tests w/coverage', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final lcovFile = File( + p.join(tempDirectory.path, 'coverage', 'lcov.info'), + ); + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + lcovFile.createSync(recursive: true); + + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + collectCoverage: true, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable( + [ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ], + ), + onStart: () { + expect(lcovFile.existsSync(), isFalse); + lcovFile.createSync(recursive: true); + }, + ), + logger: logger, + ), + completion(equals([ExitCode.success.code])), + ); + expect( + stdoutLogs, + equals([ + 'Running "flutter test" in . ...\n', + contains('All tests passed!'), + ]), + ); + expect(testRunnerArgs, equals(['--coverage'])); + }); + + test('runs tests w/coverage + min-coverage 100 (pass)', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + collectCoverage: true, + minCoverage: 100, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable( + [ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ], + ), + onStart: () { + File(p.join(tempDirectory.path, 'coverage', 'lcov.info')) + ..createSync(recursive: true) + ..writeAsStringSync(lcov100); + }, + ), + logger: logger, + ), + completion(equals([ExitCode.success.code])), + ); + expect( + stdoutLogs, + equals([ + 'Running "flutter test" in . ...\n', + contains('All tests passed!'), + ]), + ); + expect(testRunnerArgs, equals(['--coverage'])); + }); + + test('runs tests w/coverage + min-coverage 100 (fail)', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + + await expectLater( + () => TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + collectCoverage: true, + minCoverage: 100, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable( + [ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ], + ), + onStart: () { + File(p.join(tempDirectory.path, 'coverage', 'lcov.info')) + ..createSync(recursive: true) + ..writeAsStringSync(lcov95); + }, + ), + logger: logger, + ), + throwsA( + isA().having( + (e) => e.coverage, + 'coverage', + 95.0, + ), + ), + ); + expect( + stdoutLogs, + equals([ + 'Running "flutter test" in . ...\n', + contains('All tests passed!'), + ]), + ); + expect(stderrLogs, isEmpty); + expect(testRunnerArgs, equals(['--coverage'])); + }); + + test( + 'runs tests w/coverage + min-coverage 100 + exclude coverage (pass)', + () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + collectCoverage: true, + minCoverage: 100, + excludeFromCoverage: + '/bloc/packages/bloc/lib/src/bloc_observer.dart', + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable( + [ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ], + ), + onStart: () { + File(p.join(tempDirectory.path, 'coverage', 'lcov.info')) + ..createSync(recursive: true) + ..writeAsStringSync(lcov95); + }, + ), + logger: logger, + ), + completion(equals([ExitCode.success.code])), + ); + expect( + stdoutLogs, + equals([ + 'Running "flutter test" in . ...\n', + contains('All tests passed!'), + ]), + ); + expect(stderrLogs, isEmpty); + expect(testRunnerArgs, equals(['--coverage'])); + }, + ); + + test( + 'runs tests w/coverage + min-coverage 100 + recursive (pass)', + () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + + final tempNestedDirectory = Directory( + p.join(tempDirectory.path, 'test'), + )..createSync(); + File(p.join(tempNestedDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempNestedDirectory.path, 'test')).createSync(); + + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + collectCoverage: true, + minCoverage: 100, + recursive: true, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable( + [ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ], + ), + onStart: () { + File(p.join(tempDirectory.path, 'coverage', 'lcov.info')) + ..createSync(recursive: true) + ..writeAsStringSync(lcov100); + File( + p.join(tempNestedDirectory.path, 'coverage', 'lcov.info'), + ) + ..createSync(recursive: true) + ..writeAsStringSync(lcov100); + }, + ), + logger: logger, + ), + completion(equals([ExitCode.success.code, ExitCode.success.code])), + ); + + final nestedRelativePath = p.relative( + tempNestedDirectory.path, + from: tempDirectory.path, + ); + final relativePathPrefix = '.${p.context.separator}'; + + expect( + stdoutLogs, + unorderedEquals([ + '''Running "flutter test" in $relativePathPrefix$nestedRelativePath ...\n''', + contains('All tests passed!'), + 'Running "flutter test" in . ...\n', + contains('All tests passed!'), + ]), + ); + expect(testRunnerArgs, equals(['--coverage', '--coverage'])); + }, + ); + + test( + 'runs tests w/coverage + min-coverage 100 + recursive (fail)', + () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + + final tempNestedDirectory = Directory( + p.join(tempDirectory.path, 'test'), + )..createSync(); + File(p.join(tempNestedDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempNestedDirectory.path, 'test')).createSync(); + + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + collectCoverage: true, + minCoverage: 100, + recursive: true, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable( + [ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ], + ), + onStart: () { + File(p.join(tempDirectory.path, 'coverage', 'lcov.info')) + ..createSync(recursive: true) + ..writeAsStringSync(lcov100); + File( + p.join(tempNestedDirectory.path, 'coverage', 'lcov.info'), + ) + ..createSync(recursive: true) + ..writeAsStringSync(lcov95); + }, + ), + logger: logger, + ), + throwsA( + isA().having( + (e) => e.coverage, + 'coverage', + 95.0, + ), + ), + ); + + final nestedRelativePath = p.relative( + tempNestedDirectory.path, + from: tempDirectory.path, + ); + final relativePathPrefix = '.${p.context.separator}'; + + expect( + stdoutLogs, + unorderedEquals([ + 'Running "flutter test" in ' + '. ...\n', + contains('All tests passed!'), + '''Running "flutter test" in $relativePathPrefix$nestedRelativePath ...\n''', + contains('All tests passed!'), + ]), + ); + expect(stderrLogs, isEmpty); + expect(testRunnerArgs, equals(['--coverage', '--coverage'])); + }, + ); + + test('runs tests w/optimizations (passing)', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final originalVars = { + 'package-root': tempDirectory.path, + }; + final updatedVars = { + 'package-root': tempDirectory.path, + 'foo': 'bar', + }; + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + when( + () => hooks.preGen( + vars: any(named: 'vars'), + onVarsChanged: any(named: 'onVarsChanged'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((invocation) async { + (invocation.namedArguments[#onVarsChanged] + as void Function( + Map vars, + )) + .call(updatedVars); + }); + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + optimizePerformance: true, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + logger: logger, + overrideTestRunner: testRunner( + Stream.fromIterable( + [ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ], + ), + ), + buildGenerator: generatorBuilder(), + ), + completion(equals([ExitCode.success.code])), + ); + expect( + stdoutLogs, + equals([ + 'Running "flutter test" in . ...\n', + contains('All tests passed!'), + ]), + ); + expect( + testRunnerArgs, + equals([p.join('test', '.test_optimizer.dart')]), + ); + verify(() => logger.progress('Optimizing tests')).called(1); + verify( + () => hooks.preGen( + vars: originalVars, + onVarsChanged: any(named: 'onVarsChanged'), + workingDirectory: tempDirectory.path, + ), + ).called(1); + verify( + () => generator.generate( + any(), + vars: updatedVars, + fileConflictResolution: FileConflictResolution.overwrite, + ), + ).called(1); + verify(() => progress.complete()).called(1); + }); + + test('runs tests w/optimizations (failing)', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable([ + SuiteTestEvent( + suite: TestSuite( + id: 4, + platform: 'vm', + path: '${tempDirectory.path}/test/.test_optimizer.dart', + ), + time: 0, + ), + GroupTestEvent( + group: TestGroup( + id: 10, + suiteID: 4, + name: 'app/view/app_test.dart', + metadata: TestMetadata( + skip: false, + ), + testCount: 1, + ), + time: 0, + ), + GroupTestEvent( + group: TestGroup( + id: 99, + suiteID: 4, + name: 'app/view/app_test.dart CounterCubit', + metadata: TestMetadata( + skip: false, + ), + testCount: 1, + ), + time: 0, + ), + TestStartEvent( + test: Test( + id: 0, + name: + 'app/view/app_test.dart CounterCubit emits [1] when increment is called', + suiteID: 4, + groupIDs: [10, 99], + metadata: TestMetadata(skip: false), + ), + time: 0, + ), + ErrorTestEvent( + testID: 0, + error: 'error', + stackTrace: stack_trace.Trace.parse( + 'test/example_test.dart 4 main', + ).toString(), + isFailure: true, + time: 0, + ), + const DoneTestEvent(success: false, time: 0), + const ExitTestEvent(exitCode: 1, time: 0), + ]), + ), + logger: logger, + ), + completion(equals([ExitCode.unavailable.code])), + ); + expect( + stderrLogs, + equals([ + '\x1B[2K\rerror', + '\x1B[2K\rtest/example_test.dart 4 main\n', + '\x1B[2K\rFailing Tests:\n' + '\x1B[2K\r - test/app/view/app_test.dart \n' + '''\x1B[2K\r \t- [FAILED] CounterCubit emits [1] when increment is called\n''', + ]), + ); + }); + }); + }); +} From d92f0d08ae85d1c7e1a297be1960c2ee023de6a5 Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Mon, 11 Aug 2025 10:49:59 -0300 Subject: [PATCH 03/11] fixig tests --- lib/src/commands/dart/dart.dart | 4 ++-- test/src/cli/test_runner_cli_test.dart | 7 +++++++ test/src/command_runner_test.dart | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/src/commands/dart/dart.dart b/lib/src/commands/dart/dart.dart index ed01f481b..5a6ed241b 100644 --- a/lib/src/commands/dart/dart.dart +++ b/lib/src/commands/dart/dart.dart @@ -3,7 +3,7 @@ import 'package:mason/mason.dart'; import 'package:very_good_cli/src/commands/dart/commands/commands.dart'; /// {@template dart_command} -/// `very_good dart` command for running dart processes. +/// `very_good dart` command for running dart related commands. /// {@endtemplate} class DartCommand extends Command { /// {@macro packages_command} @@ -12,7 +12,7 @@ class DartCommand extends Command { } @override - String get description => 'Command for managing packages.'; + String get description => 'Command for running dart related commands.'; @override String get name => 'dart'; diff --git a/test/src/cli/test_runner_cli_test.dart b/test/src/cli/test_runner_cli_test.dart index e974100c2..91ee3f640 100644 --- a/test/src/cli/test_runner_cli_test.dart +++ b/test/src/cli/test_runner_cli_test.dart @@ -21,8 +21,15 @@ class _MockProgress extends Mock implements Progress {} class _MockLogger extends Mock implements Logger {} +class _FakeGeneratorTarget extends Fake implements GeneratorTarget {} + void main() { group('TestCLIRunner', () { + setUpAll(() { + registerFallbackValue(_FakeGeneratorTarget()); + registerFallbackValue(FileConflictResolution.prompt); + }); + group('.test', () { late Progress progress; late Logger logger; diff --git a/test/src/command_runner_test.dart b/test/src/command_runner_test.dart index bb0275c94..a323dec43 100644 --- a/test/src/command_runner_test.dart +++ b/test/src/command_runner_test.dart @@ -34,6 +34,7 @@ const expectedUsage = [ 'Available commands:\n', ' create very_good create [arguments]\n', ''' Creates a new very good project in the specified directory.\n''', + ' dart Command for running dart related commands.\n', ' packages Command for managing packages.\n', ' test Run tests in a Dart or Flutter project.\n', ' update Update Very Good CLI.\n', From b10f6519a73b4774f7156ef37680315ec5c248b4 Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Mon, 11 Aug 2025 14:41:06 -0300 Subject: [PATCH 04/11] more tests --- lib/src/commands/dart/commands/commands.dart | 2 +- ...t_commands.dart => dart_test_command.dart} | 2 +- .../dart/commands/dart_test_test.dart | 560 ++++++++++++++++++ test/src/commands/dart/dart_test.dart | 39 ++ 4 files changed, 601 insertions(+), 2 deletions(-) rename lib/src/commands/dart/commands/{dart_test_commands.dart => dart_test_command.dart} (98%) create mode 100644 test/src/commands/dart/commands/dart_test_test.dart create mode 100644 test/src/commands/dart/dart_test.dart diff --git a/lib/src/commands/dart/commands/commands.dart b/lib/src/commands/dart/commands/commands.dart index 4e303389c..4d9d5bb07 100644 --- a/lib/src/commands/dart/commands/commands.dart +++ b/lib/src/commands/dart/commands/commands.dart @@ -1 +1 @@ -export 'dart_test_commands.dart'; +export 'dart_test_command.dart'; diff --git a/lib/src/commands/dart/commands/dart_test_commands.dart b/lib/src/commands/dart/commands/dart_test_command.dart similarity index 98% rename from lib/src/commands/dart/commands/dart_test_commands.dart rename to lib/src/commands/dart/commands/dart_test_command.dart index 81e446b2e..5ba7d8e4e 100644 --- a/lib/src/commands/dart/commands/dart_test_commands.dart +++ b/lib/src/commands/dart/commands/dart_test_command.dart @@ -104,7 +104,7 @@ class DartTestCommand extends Command { final DartInstalledCommand _dartInstalled; @override - String get description => 'Command for managing packages.'; + String get description => 'Run tests in a Dart project.'; @override String get name => 'test'; diff --git a/test/src/commands/dart/commands/dart_test_test.dart b/test/src/commands/dart/commands/dart_test_test.dart new file mode 100644 index 000000000..30762e8ac --- /dev/null +++ b/test/src/commands/dart/commands/dart_test_test.dart @@ -0,0 +1,560 @@ +// Expected usage of the plugin will need to be adjacent strings due to format +// and also be longer than 80 chars. +// ignore_for_file: no_adjacent_strings_in_list, lines_longer_than_80_chars, implicit_call_tearoffs + +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:mason/mason.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; +import 'package:very_good_cli/src/cli/cli.dart'; +import 'package:very_good_cli/src/commands/dart/commands/commands.dart'; + +import '../../../../helpers/helpers.dart'; + +class _MockLogger extends Mock implements Logger {} + +class _MockArgResults extends Mock implements ArgResults {} + +class _MockDartTestCommand extends Mock implements DartTestCommandCall {} + +const expectedTestUsage = [ + 'Run tests in a Dart project.\n' + '\n' + 'Usage: very_good dart test [arguments]\n' + '-h, --help Print this usage information.\n' + ' --coverage Whether to collect coverage information.\n' + '-r, --recursive Run tests recursively for all nested packages.\n' + ' --[no-]optimization Whether to apply optimizations for test performance.\n' + ' (defaults to on)\n' + '-j, --concurrency The number of concurrent test suites run.\n' + ' (defaults to "4")\n' + '-t, --tags Run only tests associated with the specified tags.\n' + ' --exclude-coverage A glob which will be used to exclude files that match from the coverage.\n' + '-x, --exclude-tags Run only tests that do not have the specified tags.\n' + ' --min-coverage Whether to enforce a minimum coverage percentage.\n' + ' --test-randomize-ordering-seed The seed to randomize the execution order of test cases within test files.\n' + ' --force-ansi Whether to force ansi output. If not specified, it will maintain the default behavior based on stdout and stderr.\n' + '\n' + 'Run "very_good help" to see global options.', +]; + +// A concrete class should have methods with body +// and we just want to mock this class for the test. +// ignore: one_member_abstracts +abstract class DartTestCommandCall { + Future> call({ + String cwd = '.', + bool recursive = false, + bool collectCoverage = false, + bool optimizePerformance = false, + double? minCoverage, + String? excludeFromCoverage, + String? randomSeed, + List? arguments, + Logger? logger, + void Function(String)? stdout, + void Function(String)? stderr, + bool? forceAnsi, + }); +} + +void main() { + group('dart test', () { + final cwd = Directory.current; + const concurrency = '4'; + const defaultArguments = ['-j', concurrency]; + + late Logger logger; + late bool isFlutterInstalled; + late ArgResults argResults; + late DartTestCommandCall dartTest; + late DartTestCommand testCommand; + + setUp(() { + logger = _MockLogger(); + isFlutterInstalled = true; + argResults = _MockArgResults(); + dartTest = _MockDartTestCommand(); + testCommand = DartTestCommand( + logger: logger, + dartInstalled: ({required Logger logger}) async => isFlutterInstalled, + dartTest: dartTest.call, + )..argResultOverrides = argResults; + when( + () => dartTest( + cwd: any(named: 'cwd'), + recursive: any(named: 'recursive'), + collectCoverage: any(named: 'collectCoverage'), + optimizePerformance: any(named: 'optimizePerformance'), + minCoverage: any(named: 'minCoverage'), + excludeFromCoverage: any(named: 'excludeFromCoverage'), + randomSeed: any(named: 'randomSeed'), + arguments: any(named: 'arguments'), + logger: any(named: 'logger'), + stdout: any(named: 'stdout'), + stderr: any(named: 'stderr'), + forceAnsi: any(named: 'forceAnsi'), + ), + ).thenAnswer((_) async => [0]); + when(() => argResults['concurrency']).thenReturn(concurrency); + when(() => argResults['recursive']).thenReturn(false); + when(() => argResults['coverage']).thenReturn(false); + when(() => argResults['optimization']).thenReturn(true); + when(() => argResults.rest).thenReturn([]); + }); + + tearDown(() { + Directory.current = cwd; + }); + + test( + 'help', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + final result = await commandRunner.run(['dart', 'test', '--help']); + expect(printLogs, equals(expectedTestUsage)); + expect(result, equals(ExitCode.success.code)); + + printLogs.clear(); + + final resultAbbr = await commandRunner.run(['dart', 'test', '-h']); + expect(printLogs, equals(expectedTestUsage)); + expect(resultAbbr, equals(ExitCode.success.code)); + }), + ); + + test( + 'throws pubspec not found exception ' + 'when no pubspec.yaml exists', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + Directory.current = tempDirectory.path; + final result = await commandRunner.run(['test']); + expect(result, equals(ExitCode.noInput.code)); + verify(() { + logger.err(any(that: contains('Could not find a pubspec.yaml in'))); + }).called(1); + }), + ); + + test( + 'completes normally when no pubspec.yaml exists (recursive)', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + Directory.current = tempDirectory.path; + + Directory(path.join(Directory.current.path, 'project')).createSync(); + File( + path.join( + Directory.current.path, + 'project', + 'pubspec.yaml', + ), + ).createSync(); + + final result = await commandRunner.run(['test', '-r']); + expect(result, equals(ExitCode.success.code)); + }), + ); + + test('completes normally', () async { + final result = await testCommand.run(); + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + optimizePerformance: true, + arguments: defaultArguments, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }); + + test('exits with 70 when tests do not pass', () async { + when( + () => dartTest( + cwd: any(named: 'cwd'), + recursive: any(named: 'recursive'), + collectCoverage: any(named: 'collectCoverage'), + optimizePerformance: any(named: 'optimizePerformance'), + minCoverage: any(named: 'minCoverage'), + excludeFromCoverage: any(named: 'excludeFromCoverage'), + arguments: any(named: 'arguments'), + logger: any(named: 'logger'), + stdout: any(named: 'stdout'), + stderr: any(named: 'stderr'), + ), + ).thenAnswer( + (_) async => [ExitCode.success.code, ExitCode.unavailable.code], + ); + final result = await testCommand.run(); + expect(result, equals(ExitCode.unavailable.code)); + }); + + test('completes normally --recursive', () async { + when(() => argResults['recursive']).thenReturn(true); + final result = await testCommand.run(); + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + recursive: true, + optimizePerformance: true, + arguments: defaultArguments, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }); + + test('completes normally --concurrency 1', () async { + when(() => argResults['concurrency']).thenReturn('1'); + final result = await testCommand.run(); + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + arguments: ['-j', '1'], + optimizePerformance: true, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }); + + test('completes normally --no-optimization', () async { + when(() => argResults['optimization']).thenReturn(false); + final result = await testCommand.run(); + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + arguments: defaultArguments, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }); + + test('completes normally --test-randomize-ordering-seed random', () async { + when( + () => argResults['test-randomize-ordering-seed'], + ).thenReturn('random'); + final result = await testCommand.run(); + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + arguments: defaultArguments, + optimizePerformance: true, + randomSeed: any(named: 'randomSeed', that: isNotEmpty), + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }); + + test( + 'completes normally --test-randomize-ordering-seed 2305182648', + () async { + const randomSeed = '2305182648'; + when( + () => argResults['test-randomize-ordering-seed'], + ).thenReturn(randomSeed); + final result = await testCommand.run(); + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + arguments: defaultArguments, + randomSeed: randomSeed, + optimizePerformance: true, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }, + ); + + test('completes normally --coverage', () async { + when(() => argResults['coverage']).thenReturn(true); + final result = await testCommand.run(); + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + collectCoverage: true, + optimizePerformance: true, + arguments: defaultArguments, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }); + + test('completes normally -t test-tag', () async { + when(() => argResults['tags']).thenReturn('test-tag'); + final result = await testCommand.run(); + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + optimizePerformance: true, + arguments: ['-t', 'test-tag', ...defaultArguments], + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }); + + test('completes normally -x test-tag', () async { + when(() => argResults['exclude-tags']).thenReturn('test-tag'); + final result = await testCommand.run(); + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + optimizePerformance: true, + arguments: ['-x', 'test-tag', ...defaultArguments], + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }); + + test('completes normally --coverage --min-coverage 0', () async { + when(() => argResults['coverage']).thenReturn(true); + when(() => argResults['min-coverage']).thenReturn('0'); + final result = await testCommand.run(); + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + optimizePerformance: true, + collectCoverage: true, + arguments: defaultArguments, + minCoverage: 0, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }); + + test( + 'enables coverage collection when --min-coverage is supplied', + () async { + when(() => argResults['min-coverage']).thenReturn('0'); + final result = await testCommand.run(); + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + optimizePerformance: true, + collectCoverage: true, + arguments: defaultArguments, + minCoverage: 0, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }, + ); + + test('fails when coverage not met', () async { + when(() => argResults['coverage']).thenReturn(true); + when(() => argResults['min-coverage']).thenReturn('100'); + const exception = MinCoverageNotMet(0); + when( + () => dartTest( + cwd: any(named: 'cwd'), + recursive: any(named: 'recursive'), + collectCoverage: any(named: 'collectCoverage'), + optimizePerformance: any(named: 'optimizePerformance'), + minCoverage: any(named: 'minCoverage'), + excludeFromCoverage: any(named: 'excludeFromCoverage'), + arguments: any(named: 'arguments'), + logger: any(named: 'logger'), + stdout: any(named: 'stdout'), + stderr: any(named: 'stderr'), + ), + ).thenThrow(exception); + final result = await testCommand.run(); + expect(result, equals(ExitCode.unavailable.code)); + verify( + () => dartTest( + optimizePerformance: true, + collectCoverage: true, + arguments: defaultArguments, + minCoverage: 100, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + verify( + () => logger.err('Expected coverage >= 100.00% but actual is 0.00%.'), + ).called(1); + }); + + test('displays required precision see why coverage was not met', () async { + when(() => argResults['coverage']).thenReturn(true); + when(() => argResults['min-coverage']).thenReturn('100'); + const exception = MinCoverageNotMet(99.999995); + when( + () => dartTest( + cwd: any(named: 'cwd'), + recursive: any(named: 'recursive'), + collectCoverage: any(named: 'collectCoverage'), + optimizePerformance: any(named: 'optimizePerformance'), + minCoverage: any(named: 'minCoverage'), + excludeFromCoverage: any(named: 'excludeFromCoverage'), + arguments: any(named: 'arguments'), + logger: any(named: 'logger'), + stdout: any(named: 'stdout'), + stderr: any(named: 'stderr'), + ), + ).thenThrow(exception); + final result = await testCommand.run(); + expect(result, equals(ExitCode.unavailable.code)); + verify( + () => dartTest( + optimizePerformance: true, + collectCoverage: true, + arguments: defaultArguments, + minCoverage: 100, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + verify( + () => logger.err( + 'Expected coverage >= 100.000000% but actual is 99.999995%.', + ), + ).called(1); + }); + + test( + 'exclude files from coverage when --exclude-coverage is used', + () async { + when(() => argResults['coverage']).thenReturn(true); + when( + () => argResults['exclude-coverage'], + ).thenReturn('*.g.dart'); + final result = await testCommand.run(); + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + optimizePerformance: true, + collectCoverage: true, + excludeFromCoverage: '*.g.dart', + arguments: defaultArguments, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }, + ); + + test('throws when exception occurs', () async { + final exception = Exception('oops'); + when( + () => dartTest( + cwd: any(named: 'cwd'), + recursive: any(named: 'recursive'), + collectCoverage: any(named: 'collectCoverage'), + optimizePerformance: any(named: 'optimizePerformance'), + minCoverage: any(named: 'minCoverage'), + excludeFromCoverage: any(named: 'excludeFromCoverage'), + arguments: any(named: 'arguments'), + logger: any(named: 'logger'), + stdout: any(named: 'stdout'), + stderr: any(named: 'stderr'), + ), + ).thenThrow(exception); + final result = await testCommand.run(); + expect(result, equals(ExitCode.unavailable.code)); + verify( + () => dartTest( + optimizePerformance: true, + arguments: defaultArguments, + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + verify(() => logger.err('$exception')).called(1); + }); + + test('completes normally --force-ansi', () async { + when(() => argResults['force-ansi']).thenReturn(true); + final result = await testCommand.run(); + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + optimizePerformance: true, + arguments: [ + ...defaultArguments, + ], + logger: logger, + stdout: logger.write, + stderr: logger.err, + forceAnsi: true, + ), + ).called(1); + }); + + test( + '''disables optimizePerformance when rest arguement is not an option''', + () async { + when(() => argResults.rest).thenReturn(['my-test.dart']); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + arguments: [ + ...defaultArguments, + ...argResults.rest, + ], + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }, + ); + + test( + 'enables optimizePerformance when rest arguement is an option', + () async { + when(() => argResults.rest).thenReturn(['--track-wdiget-creation']); + + final result = await testCommand.run(); + + expect(result, equals(ExitCode.success.code)); + verify( + () => dartTest( + optimizePerformance: true, + arguments: [ + ...defaultArguments, + ...argResults.rest, + ], + logger: logger, + stdout: logger.write, + stderr: logger.err, + ), + ).called(1); + }, + ); + }); +} diff --git a/test/src/commands/dart/dart_test.dart b/test/src/commands/dart/dart_test.dart new file mode 100644 index 000000000..2e104169f --- /dev/null +++ b/test/src/commands/dart/dart_test.dart @@ -0,0 +1,39 @@ +// Expected usage of the plugin will need to be adjacent strings due to format +// and also be longer than 80 chars. +// ignore_for_file: no_adjacent_strings_in_list, lines_longer_than_80_chars + +import 'package:mason/mason.dart'; +import 'package:test/test.dart'; + +import '../../../helpers/command_helper.dart'; + +const _expectedDartUsage = [ + 'Command for running dart related commands.\n' + '\n' + 'Usage: very_good dart [arguments]\n' + '-h, --help Print this usage information.\n' + '\n' + 'Available subcommands:\n' + ' test Run tests in a Dart project.\n' + '\n' + 'Run "very_good help" to see global options.', +]; + +void main() { + group('dart', () { + test( + 'help', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + final result = await commandRunner.run(['dart', '--help']); + expect(printLogs, equals(_expectedDartUsage)); + expect(result, equals(ExitCode.success.code)); + + printLogs.clear(); + + final resultAbbr = await commandRunner.run(['dart', '-h']); + expect(printLogs, equals(_expectedDartUsage)); + expect(resultAbbr, equals(ExitCode.success.code)); + }), + ); + }); +} From a3c30badae9b2b3c73a5f9070e6dd3f4899788f3 Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Mon, 11 Aug 2025 15:00:10 -0300 Subject: [PATCH 05/11] improving tests even more --- test/src/cli/test_runner_cli_test.dart | 155 ++++++++++++++----------- 1 file changed, 85 insertions(+), 70 deletions(-) diff --git a/test/src/cli/test_runner_cli_test.dart b/test/src/cli/test_runner_cli_test.dart index 91ee3f640..8384157c2 100644 --- a/test/src/cli/test_runner_cli_test.dart +++ b/test/src/cli/test_runner_cli_test.dart @@ -84,83 +84,98 @@ void main() { stderrLogs = []; }); - test('cleanup the .test_optimizer file when SIGINT is emitted', () async { - final streamController = StreamController(); - await ProcessSignalOverrides.runZoned( - () async { + final testRunners = [ + ('flutter', Flutter.test), + ('dart', Dart.test), + ]; + + for (final testRunnerType in testRunners) { + final name = testRunnerType.$1; + final testRunner = testRunnerType.$2; + group('when the runner type is $name', () { + test( + 'cleanup the .test_optimizer file when SIGINT is emitted', + () async { + final streamController = StreamController(); + await ProcessSignalOverrides.runZoned( + () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final updatedVars = { + 'package-root': tempDirectory.path, + 'foo': 'bar', + }; + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + when( + () => hooks.preGen( + vars: any(named: 'vars'), + onVarsChanged: any(named: 'onVarsChanged'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((invocation) async { + (invocation.namedArguments[#onVarsChanged] + as void Function( + Map vars, + )) + .call(updatedVars); + }); + ProcessSignalOverrides.current?.addSIGINT(); + await testRunner( + cwd: tempDirectory.path, + optimizePerformance: true, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + logger: logger, + buildGenerator: generatorBuilder(), + ); + final filePath = p.join( + tempDirectory.path, + 'test', + '.test_optimizer.dart', + ); + final testOptimizerFile = File(filePath); + expect(testOptimizerFile.existsSync(), isFalse); + }, + sigintStream: streamController.stream, + ); + }, + ); + + test('throws when pubspec not found', () async { + await expectLater( + () => + Flutter.test(cwd: Directory.systemTemp.path, logger: logger), + throwsA(isA()), + ); + }); + + test('completes when there is no test directory', () async { final tempDirectory = Directory.systemTemp.createTempSync(); addTearDown(() => tempDirectory.deleteSync(recursive: true)); - final updatedVars = { - 'package-root': tempDirectory.path, - 'foo': 'bar', - }; - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - Directory(p.join(tempDirectory.path, 'test')).createSync(); - when( - () => hooks.preGen( - vars: any(named: 'vars'), - onVarsChanged: any(named: 'onVarsChanged'), - workingDirectory: any(named: 'workingDirectory'), + await expectLater( + Flutter.test( + cwd: tempDirectory.path, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + logger: logger, ), - ).thenAnswer((invocation) async { - (invocation.namedArguments[#onVarsChanged] - as void Function( - Map vars, - )) - .call(updatedVars); - }); - ProcessSignalOverrides.current?.addSIGINT(); - await Flutter.test( - cwd: tempDirectory.path, - optimizePerformance: true, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - logger: logger, - buildGenerator: generatorBuilder(), + completion(equals([ExitCode.success.code])), ); - final filePath = p.join( - tempDirectory.path, - 'test', - '.test_optimizer.dart', + expect( + stdoutLogs, + equals([ + 'Running "flutter test" in . ...\n', + 'No test folder found in .\n', + ]), ); - final testOptimizerFile = File(filePath); - expect(testOptimizerFile.existsSync(), isFalse); - }, - sigintStream: streamController.stream, - ); - }); - - test('throws when pubspec not found', () async { - await expectLater( - () => Flutter.test(cwd: Directory.systemTemp.path, logger: logger), - throwsA(isA()), - ); - }); - - test('completes when there is no test directory', () async { - final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); - - File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); - await expectLater( - Flutter.test( - cwd: tempDirectory.path, - stdout: stdoutLogs.add, - stderr: stderrLogs.add, - logger: logger, - ), - completion(equals([ExitCode.success.code])), - ); - expect( - stdoutLogs, - equals([ - 'Running "flutter test" in . ...\n', - 'No test folder found in .\n', - ]), - ); - }); + }); + }); + } test('runs tests and shows timer until tests start', () async { final tempDirectory = Directory.systemTemp.createTempSync(); From 98f7170364c6d32d1c8b6dc23a27416be638b02f Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Mon, 11 Aug 2025 15:17:43 -0300 Subject: [PATCH 06/11] fixing some more tests --- lib/src/commands/dart/commands/dart_test_command.dart | 2 +- test/src/commands/dart/commands/dart_test_test.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/commands/dart/commands/dart_test_command.dart b/lib/src/commands/dart/commands/dart_test_command.dart index 5ba7d8e4e..b9c095626 100644 --- a/lib/src/commands/dart/commands/dart_test_command.dart +++ b/lib/src/commands/dart/commands/dart_test_command.dart @@ -124,7 +124,7 @@ class DartTestCommand extends Command { if (!recursive && !pubspec.existsSync()) { _logger.err(''' Could not find a pubspec.yaml in $targetPath. -This command should be run from the root of your Flutter project.'''); +This command should be run from the root of your Dart project.'''); return ExitCode.noInput.code; } diff --git a/test/src/commands/dart/commands/dart_test_test.dart b/test/src/commands/dart/commands/dart_test_test.dart index 30762e8ac..1128e9f32 100644 --- a/test/src/commands/dart/commands/dart_test_test.dart +++ b/test/src/commands/dart/commands/dart_test_test.dart @@ -133,7 +133,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); Directory.current = tempDirectory.path; - final result = await commandRunner.run(['test']); + final result = await commandRunner.run(['dart', 'test']); expect(result, equals(ExitCode.noInput.code)); verify(() { logger.err(any(that: contains('Could not find a pubspec.yaml in'))); @@ -158,7 +158,7 @@ void main() { ), ).createSync(); - final result = await commandRunner.run(['test', '-r']); + final result = await commandRunner.run(['dart', 'test', '-r']); expect(result, equals(ExitCode.success.code)); }), ); From 46ac21c966a691d6f4cd0efae10cfa78d119ce3a Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Mon, 11 Aug 2025 15:26:11 -0300 Subject: [PATCH 07/11] parameter adjustment --- lib/src/commands/dart/commands/dart_test_command.dart | 4 ++-- lib/src/commands/dart/dart.dart | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/commands/dart/commands/dart_test_command.dart b/lib/src/commands/dart/commands/dart_test_command.dart index b9c095626..845410092 100644 --- a/lib/src/commands/dart/commands/dart_test_command.dart +++ b/lib/src/commands/dart/commands/dart_test_command.dart @@ -34,10 +34,10 @@ typedef DartTestCommandCall = class DartTestCommand extends Command { /// {@macro packages_command} DartTestCommand({ - Logger? logger, + required Logger logger, DartTestCommandCall? dartTest, DartInstalledCommand? dartInstalled, - }) : _logger = logger ?? Logger(), + }) : _logger = logger, _dartTest = dartTest ?? Dart.test, _dartInstalled = dartInstalled ?? Dart.installed { argParser diff --git a/lib/src/commands/dart/dart.dart b/lib/src/commands/dart/dart.dart index 5a6ed241b..3f75faf56 100644 --- a/lib/src/commands/dart/dart.dart +++ b/lib/src/commands/dart/dart.dart @@ -7,7 +7,7 @@ import 'package:very_good_cli/src/commands/dart/commands/commands.dart'; /// {@endtemplate} class DartCommand extends Command { /// {@macro packages_command} - DartCommand({Logger? logger}) { + DartCommand({required Logger logger}) { addSubcommand(DartTestCommand(logger: logger)); } From de482d77e19d7970ecbb56aaa272f8540806e335 Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Tue, 12 Aug 2025 14:26:18 -0300 Subject: [PATCH 08/11] tweaks --- lib/src/cli/common.dart | 1 - lib/src/cli/dart_cli.dart | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 lib/src/cli/common.dart diff --git a/lib/src/cli/common.dart b/lib/src/cli/common.dart deleted file mode 100644 index 8b1378917..000000000 --- a/lib/src/cli/common.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lib/src/cli/dart_cli.dart b/lib/src/cli/dart_cli.dart index 35a98669c..656f62b0c 100644 --- a/lib/src/cli/dart_cli.dart +++ b/lib/src/cli/dart_cli.dart @@ -93,7 +93,7 @@ class Dart { await Future.wait(processes); } - /// Run tests (`flutter test`). + /// Run tests (`dart test`). /// Returns a list of exit codes for each test process. static Future> test({ required Logger logger, From 4db0967541648377936425381cdcb1180e43218e Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Tue, 12 Aug 2025 14:31:38 -0300 Subject: [PATCH 09/11] additional tweaks --- lib/src/commands/test/test.dart | 4 +++- test/src/command_runner_test.dart | 4 +++- test/src/commands/test/test_test.dart | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/src/commands/test/test.dart b/lib/src/commands/test/test.dart index 42a0aacf4..07ea817fb 100644 --- a/lib/src/commands/test/test.dart +++ b/lib/src/commands/test/test.dart @@ -135,7 +135,9 @@ class TestCommand extends Command { final FlutterTestCommand _flutterTest; @override - String get description => 'Run tests in a Dart or Flutter project.'; + String get description => + 'Run `flutter test` in a project. (Check ' + 'very_good dart test for running `dart test` instead.)'; @override String get name => 'test'; diff --git a/test/src/command_runner_test.dart b/test/src/command_runner_test.dart index a323dec43..0ddc9b9f1 100644 --- a/test/src/command_runner_test.dart +++ b/test/src/command_runner_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + import 'dart:async'; import 'dart:io'; @@ -36,7 +38,7 @@ const expectedUsage = [ ''' Creates a new very good project in the specified directory.\n''', ' dart Command for running dart related commands.\n', ' packages Command for managing packages.\n', - ' test Run tests in a Dart or Flutter project.\n', + ' test Run `flutter test` in a project. (Check very_good dart test for running `dart test` instead.)\n', ' update Update Very Good CLI.\n', '\n', 'Run "very_good help " for more information about a command.', diff --git a/test/src/commands/test/test_test.dart b/test/src/commands/test/test_test.dart index fd70ca420..826cc0fca 100644 --- a/test/src/commands/test/test_test.dart +++ b/test/src/commands/test/test_test.dart @@ -21,7 +21,7 @@ class _MockArgResults extends Mock implements ArgResults {} class _MockFlutterTestCommand extends Mock implements FlutterTestCommand {} const expectedTestUsage = [ - 'Run tests in a Dart or Flutter project.\n' + 'Run `flutter test` in a project. (Check very_good dart test for running `dart test` instead.)\n' '\n' 'Usage: very_good test [arguments]\n' '-h, --help Print this usage information.\n' From 3d3640095ce40e4cc9ee2310337f1c01b09dcea3 Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Tue, 12 Aug 2025 14:42:57 -0300 Subject: [PATCH 10/11] fix lint --- test/src/command_runner_test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/test/src/command_runner_test.dart b/test/src/command_runner_test.dart index 0ddc9b9f1..fe970b0cd 100644 --- a/test/src/command_runner_test.dart +++ b/test/src/command_runner_test.dart @@ -1,3 +1,4 @@ +// Ignoring for test // ignore_for_file: lines_longer_than_80_chars import 'dart:async'; From 4711b625a7133377e1f1e905f73d5f09a2cae661 Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Tue, 12 Aug 2025 15:23:09 -0300 Subject: [PATCH 11/11] PR suggestion --- .../dart/commands/dart_test_command.dart | 122 +++++++++++--- lib/src/commands/test/test.dart | 159 +++++++++++++----- 2 files changed, 216 insertions(+), 65 deletions(-) diff --git a/lib/src/commands/dart/commands/dart_test_command.dart b/lib/src/commands/dart/commands/dart_test_command.dart index 845410092..fcc68c3aa 100644 --- a/lib/src/commands/dart/commands/dart_test_command.dart +++ b/lib/src/commands/dart/commands/dart_test_command.dart @@ -8,6 +8,86 @@ import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; import 'package:very_good_cli/src/cli/cli.dart'; +/// Options for configuring the Dart test command. +class DartTestOptions { + DartTestOptions._({ + required this.concurrency, + required this.collectCoverage, + required this.minCoverage, + required this.excludeTags, + required this.tags, + required this.excludeFromCoverage, + required this.randomSeed, + required this.optimizePerformance, + required this.forceAnsi, + required this.rest, + }); + + /// Parses [ArgResults] into a [DartTestOptions] instance. + factory DartTestOptions.parse(ArgResults argResults) { + final concurrency = argResults['concurrency'] as String; + final collectCoverage = argResults['coverage'] as bool; + final minCoverage = double.tryParse( + argResults['min-coverage'] as String? ?? '', + ); + final excludeTags = argResults['exclude-tags'] as String?; + final tags = argResults['tags'] as String?; + final excludeFromCoverage = argResults['exclude-coverage'] as String?; + final randomOrderingSeed = + argResults['test-randomize-ordering-seed'] as String?; + final randomSeed = randomOrderingSeed == 'random' + ? Random().nextInt(4294967295).toString() + : randomOrderingSeed; + final optimizePerformance = argResults['optimization'] as bool; + final forceAnsi = argResults['force-ansi'] as bool?; + final rest = argResults.rest; + + return DartTestOptions._( + concurrency: concurrency, + collectCoverage: collectCoverage, + minCoverage: minCoverage, + excludeTags: excludeTags, + tags: tags, + excludeFromCoverage: excludeFromCoverage, + randomSeed: randomSeed, + optimizePerformance: optimizePerformance, + forceAnsi: forceAnsi, + rest: rest, + ); + } + + /// The number of concurrent test suites run. + final String concurrency; + + /// Whether to collect coverage information. + final bool collectCoverage; + + /// Whether to enforce a minimum coverage percentage. + final double? minCoverage; + + /// Run only tests that do not have the specified tags. + final String? excludeTags; + + /// Run only tests associated with the specified tags. + final String? tags; + + /// A glob which will be used to exclude files that match from the coverage. + final String? excludeFromCoverage; + + /// The seed to randomize the execution order of test cases within test files. + final String? randomSeed; + + /// Whether to apply optimizations for test performance. + final bool optimizePerformance; + + /// Whether to force ansi output. If not specified, it will maintain the + /// default behavior based on stdout and stderr. + final bool? forceAnsi; + + /// The remaining arguments passed to the `dart test` command. + final List rest; +} + /// Signature for the [Dart.installed] method. typedef DartInstalledCommand = Future Function({required Logger logger}); @@ -128,43 +208,31 @@ This command should be run from the root of your Dart project.'''); return ExitCode.noInput.code; } - final concurrency = _argResults['concurrency'] as String; - final collectCoverage = _argResults['coverage'] as bool; - final minCoverage = double.tryParse( - _argResults['min-coverage'] as String? ?? '', - ); - final excludeTags = _argResults['exclude-tags'] as String?; - final tags = _argResults['tags'] as String?; final isDartInstalled = await _dartInstalled(logger: _logger); - final excludeFromCoverage = _argResults['exclude-coverage'] as String?; - final randomOrderingSeed = - _argResults['test-randomize-ordering-seed'] as String?; - final randomSeed = randomOrderingSeed == 'random' - ? Random().nextInt(4294967295).toString() - : randomOrderingSeed; - final optimizePerformance = _argResults['optimization'] as bool; - final forceAnsi = _argResults['force-ansi'] as bool?; - final rest = _argResults.rest; + + final options = DartTestOptions.parse(_argResults); if (isDartInstalled) { try { final results = await _dartTest( optimizePerformance: - optimizePerformance && !TestCLIRunner.isTargettingTestFiles(rest), + options.optimizePerformance && + !TestCLIRunner.isTargettingTestFiles(options.rest), recursive: recursive, logger: _logger, stdout: _logger.write, stderr: _logger.err, - collectCoverage: collectCoverage || minCoverage != null, - minCoverage: minCoverage, - excludeFromCoverage: excludeFromCoverage, - randomSeed: randomSeed, - forceAnsi: forceAnsi, + collectCoverage: + options.collectCoverage || options.minCoverage != null, + minCoverage: options.minCoverage, + excludeFromCoverage: options.excludeFromCoverage, + randomSeed: options.randomSeed, + forceAnsi: options.forceAnsi, arguments: [ - if (excludeTags != null) ...['-x', excludeTags], - if (tags != null) ...['-t', tags], - ...['-j', concurrency], - ...rest, + if (options.excludeTags != null) ...['-x', options.excludeTags!], + if (options.tags != null) ...['-t', options.tags!], + ...['-j', options.concurrency], + ...options.rest, ], ); if (results.any((code) => code != ExitCode.success.code)) { @@ -173,7 +241,7 @@ This command should be run from the root of your Dart project.'''); } on MinCoverageNotMet catch (e) { TestCLIRunner.handleMinCoverageNotMet( logger: _logger, - minCoverage: minCoverage, + minCoverage: options.minCoverage, e: e, ); return ExitCode.unavailable.code; diff --git a/lib/src/commands/test/test.dart b/lib/src/commands/test/test.dart index 07ea817fb..30a3e1096 100644 --- a/lib/src/commands/test/test.dart +++ b/lib/src/commands/test/test.dart @@ -8,6 +8,106 @@ import 'package:path/path.dart' as path; import 'package:universal_io/io.dart'; import 'package:very_good_cli/src/cli/cli.dart'; +/// Options for configuring the Flutter test command. +class FlutterTestOptions { + FlutterTestOptions._({ + required this.concurrency, + required this.collectCoverage, + required this.minCoverage, + required this.excludeTags, + required this.tags, + required this.excludeFromCoverage, + required this.randomSeed, + required this.optimizePerformance, + required this.updateGoldens, + required this.forceAnsi, + required this.dartDefine, + required this.dartDefineFromFile, + required this.rest, + }); + + /// Parses [ArgResults] into a [FlutterTestOptions] instance. + factory FlutterTestOptions.parse(ArgResults argResults) { + final concurrency = argResults['concurrency'] as String; + final collectCoverage = argResults['coverage'] as bool; + final minCoverage = double.tryParse( + argResults['min-coverage'] as String? ?? '', + ); + final excludeTags = argResults['exclude-tags'] as String?; + final tags = argResults['tags'] as String?; + final excludeFromCoverage = argResults['exclude-coverage'] as String?; + final randomOrderingSeed = + argResults['test-randomize-ordering-seed'] as String?; + final randomSeed = randomOrderingSeed == 'random' + ? Random().nextInt(4294967295).toString() + : randomOrderingSeed; + final optimizePerformance = argResults['optimization'] as bool; + final updateGoldens = argResults['update-goldens'] as bool; + final forceAnsi = argResults['force-ansi'] as bool?; + final dartDefine = argResults['dart-define'] as List?; + final dartDefineFromFile = + argResults['dart-define-from-file'] as List?; + final rest = argResults.rest; + + return FlutterTestOptions._( + concurrency: concurrency, + collectCoverage: collectCoverage, + minCoverage: minCoverage, + excludeTags: excludeTags, + tags: tags, + excludeFromCoverage: excludeFromCoverage, + randomSeed: randomSeed, + optimizePerformance: optimizePerformance, + updateGoldens: updateGoldens, + forceAnsi: forceAnsi, + dartDefine: dartDefine, + dartDefineFromFile: dartDefineFromFile, + rest: rest, + ); + } + + /// The number of concurrent test suites run. + final String concurrency; + + /// Whether to collect coverage information. + final bool collectCoverage; + + /// Whether to enforce a minimum coverage percentage. + final double? minCoverage; + + /// Run only tests that do not have the specified tags. + final String? excludeTags; + + /// Run only tests associated with the specified tags. + final String? tags; + + /// A glob which will be used to exclude files that match from the coverage. + final String? excludeFromCoverage; + + /// The seed to randomize the execution order of test cases within test files. + final String? randomSeed; + + /// Whether to apply optimizations for test performance. + final bool optimizePerformance; + + /// Whether "matchesGoldenFile()" calls within your test methods should update + /// the golden files. + final bool updateGoldens; + + /// Whether to force ansi output. If not specified, it will maintain the + /// default behavior based on stdout and stderr. + final bool? forceAnsi; + + /// Optional list of dart defines + final List? dartDefine; + + /// Optional list of dart define from files + final List? dartDefineFromFile; + + /// The remaining arguments passed to the test command. + final List rest; +} + /// Signature for the [Flutter.installed] method. typedef FlutterInstalledCommand = Future Function({required Logger logger}); @@ -161,56 +261,39 @@ This command should be run from the root of your Flutter project.'''); return ExitCode.noInput.code; } - final concurrency = _argResults['concurrency'] as String; - final collectCoverage = _argResults['coverage'] as bool; - final minCoverage = double.tryParse( - _argResults['min-coverage'] as String? ?? '', - ); - final excludeTags = _argResults['exclude-tags'] as String?; - final tags = _argResults['tags'] as String?; final isFlutterInstalled = await _flutterInstalled(logger: _logger); - final excludeFromCoverage = _argResults['exclude-coverage'] as String?; - final randomOrderingSeed = - _argResults['test-randomize-ordering-seed'] as String?; - final randomSeed = randomOrderingSeed == 'random' - ? Random().nextInt(4294967295).toString() - : randomOrderingSeed; - final optimizePerformance = _argResults['optimization'] as bool; - final updateGoldens = _argResults['update-goldens'] as bool; - final forceAnsi = _argResults['force-ansi'] as bool?; - final dartDefine = _argResults['dart-define'] as List?; - final dartDefineFromFile = - _argResults['dart-define-from-file'] as List?; - final rest = _argResults.rest; + + final options = FlutterTestOptions.parse(_argResults); if (isFlutterInstalled) { try { final results = await _flutterTest( optimizePerformance: - optimizePerformance && - !TestCLIRunner.isTargettingTestFiles(rest) && - !updateGoldens, + options.optimizePerformance && + !TestCLIRunner.isTargettingTestFiles(options.rest) && + !options.updateGoldens, recursive: recursive, logger: _logger, stdout: _logger.write, stderr: _logger.err, - collectCoverage: collectCoverage || minCoverage != null, - minCoverage: minCoverage, - excludeFromCoverage: excludeFromCoverage, - randomSeed: randomSeed, - forceAnsi: forceAnsi, + collectCoverage: + options.collectCoverage || options.minCoverage != null, + minCoverage: options.minCoverage, + excludeFromCoverage: options.excludeFromCoverage, + randomSeed: options.randomSeed, + forceAnsi: options.forceAnsi, arguments: [ - if (excludeTags != null) ...['-x', excludeTags], - if (tags != null) ...['-t', tags], - if (updateGoldens) '--update-goldens', - if (dartDefine != null) - for (final value in dartDefine) '--dart-define=$value', - if (dartDefineFromFile != null) - for (final value in dartDefineFromFile) + if (options.excludeTags != null) ...['-x', options.excludeTags!], + if (options.tags != null) ...['-t', options.tags!], + if (options.updateGoldens) '--update-goldens', + if (options.dartDefine != null) + for (final value in options.dartDefine!) '--dart-define=$value', + if (options.dartDefineFromFile != null) + for (final value in options.dartDefineFromFile!) '--dart-define-from-file=$value', - ...['-j', concurrency], + ...['-j', options.concurrency], '--no-pub', - ...rest, + ...options.rest, ], ); if (results.any((code) => code != ExitCode.success.code)) { @@ -219,7 +302,7 @@ This command should be run from the root of your Flutter project.'''); } on MinCoverageNotMet catch (e) { TestCLIRunner.handleMinCoverageNotMet( logger: _logger, - minCoverage: minCoverage, + minCoverage: options.minCoverage, e: e, ); return ExitCode.unavailable.code;