LCOV - code coverage report
Current view: top level - src/cli - flutter_cli.dart (source / functions) Hit Total Coverage
Test: lcov.info Lines: 234 234 100.0 %
Date: 2023-11-15 10:29:52 Functions: 0 0 -

          Line data    Source code
       1             : part of 'cli.dart';
       2             : 
       3             : const _testOptimizerFileName = '.test_optimizer.dart';
       4             : 
       5             : /// Thrown when `flutter packages get` or `flutter pub get`
       6             : /// is executed without a `pubspec.yaml`.
       7             : class PubspecNotFound implements Exception {}
       8             : 
       9             : /// {@template coverage_not_met}
      10             : /// Thrown when `flutter test ---coverage --min-coverage`
      11             : /// does not meet the provided minimum coverage threshold.
      12             : /// {@endtemplate}
      13             : class MinCoverageNotMet implements Exception {
      14             :   /// {@macro coverage_not_met}
      15           2 :   const MinCoverageNotMet(this.coverage);
      16             : 
      17             :   /// The measured coverage percentage (total hits / total found * 100).
      18             :   final double coverage;
      19             : }
      20             : 
      21             : class _CoverageMetrics {
      22          21 :   const _CoverageMetrics._({this.totalHits = 0, this.totalFound = 0});
      23             : 
      24             :   /// Generate coverage metrics from a list of lcov records.
      25           1 :   factory _CoverageMetrics.fromLcovRecords(
      26             :     List<Record> records,
      27             :     String? excludeFromCoverage,
      28             :   ) {
      29           1 :     final glob = excludeFromCoverage != null ? Glob(excludeFromCoverage) : null;
      30           1 :     return records.fold<_CoverageMetrics>(
      31             :       const _CoverageMetrics._(),
      32           1 :       (current, record) {
      33           2 :         final found = record.lines?.found ?? 0;
      34           2 :         final hit = record.lines?.hit ?? 0;
      35           1 :         if (glob != null && record.file != null) {
      36           2 :           if (glob.matches(record.file!)) {
      37             :             return current;
      38             :           }
      39             :         }
      40           1 :         return _CoverageMetrics._(
      41           2 :           totalFound: current.totalFound + found,
      42           2 :           totalHits: current.totalHits + hit,
      43             :         );
      44             :       },
      45             :     );
      46             :   }
      47             : 
      48             :   final int totalHits;
      49             :   final int totalFound;
      50             : 
      51           7 :   double get percentage => totalFound < 1 ? 0 : (totalHits / totalFound * 100);
      52             : }
      53             : 
      54             : /// Type definition for the [flutterTest] command
      55             : /// from 'package:very_good_test_runner`.
      56             : typedef FlutterTestRunner = Stream<TestEvent> Function({
      57             :   List<String>? arguments,
      58             :   String? workingDirectory,
      59             :   Map<String, String>? environment,
      60             :   bool runInShell,
      61             : });
      62             : 
      63             : /// A method which returns a [Future<MasonGenerator>] given a [MasonBundle].
      64             : typedef GeneratorBuilder = Future<MasonGenerator> Function(MasonBundle);
      65             : 
      66             : /// Flutter CLI
      67             : class Flutter {
      68             :   /// Determine whether flutter is installed.
      69           8 :   static Future<bool> installed({
      70             :     required Logger logger,
      71             :   }) async {
      72             :     try {
      73          16 :       await _Cmd.run('flutter', ['--version'], logger: logger);
      74             :       return true;
      75             :     } catch (_) {
      76             :       return false;
      77             :     }
      78             :   }
      79             : 
      80             :   /// Install flutter dependencies (`flutter packages get`).
      81           5 :   static Future<void> packagesGet({
      82             :     required Logger logger,
      83             :     String cwd = '.',
      84             :     bool recursive = false,
      85             :     Set<String> ignore = const {},
      86             :   }) async {
      87           5 :     await _runCommand(
      88           5 :       cmd: (cwd) async {
      89           5 :         final installProgress = logger.progress(
      90           5 :           'Running "flutter packages get" in $cwd',
      91             :         );
      92             : 
      93             :         try {
      94           5 :           await _verifyGitDependencies(cwd, logger: logger);
      95             :         } catch (_) {
      96           2 :           installProgress.fail();
      97             :           rethrow;
      98             :         }
      99             : 
     100             :         try {
     101           5 :           await _Cmd.run(
     102             :             'flutter',
     103           5 :             ['packages', 'get'],
     104             :             workingDirectory: cwd,
     105             :             logger: logger,
     106             :           );
     107             :         } finally {
     108           5 :           installProgress.complete();
     109             :         }
     110             :       },
     111             :       cwd: cwd,
     112             :       recursive: recursive,
     113             :       ignore: ignore,
     114             :     );
     115             :   }
     116             : 
     117             :   /// Install dart dependencies (`flutter pub get`).
     118           4 :   static Future<void> pubGet({
     119             :     required Logger logger,
     120             :     String cwd = '.',
     121             :     bool recursive = false,
     122             :     Set<String> ignore = const {},
     123             :   }) async {
     124           4 :     await _runCommand(
     125           8 :       cmd: (cwd) => _Cmd.run(
     126             :         'flutter',
     127           4 :         ['pub', 'get'],
     128             :         workingDirectory: cwd,
     129             :         logger: logger,
     130             :       ),
     131             :       cwd: cwd,
     132             :       recursive: recursive,
     133             :       ignore: ignore,
     134             :     );
     135             :   }
     136             : 
     137             :   /// Run tests (`flutter test`).
     138             :   /// Returns a list of exit codes for each test process.
     139           2 :   static Future<List<int>> test({
     140             :     required Logger logger,
     141             :     String cwd = '.',
     142             :     bool recursive = false,
     143             :     bool collectCoverage = false,
     144             :     bool optimizePerformance = false,
     145             :     Set<String> ignore = const {},
     146             :     double? minCoverage,
     147             :     String? excludeFromCoverage,
     148             :     String? randomSeed,
     149             :     bool? forceAnsi,
     150             :     List<String>? arguments,
     151             :     void Function(String)? stdout,
     152             :     void Function(String)? stderr,
     153             :     FlutterTestRunner testRunner = flutterTest,
     154             :     GeneratorBuilder buildGenerator = MasonGenerator.fromBundle,
     155             :   }) async {
     156           2 :     return _runCommand<int>(
     157           2 :       cmd: (cwd) async {
     158           2 :         final lcovPath = p.join(cwd, 'coverage', 'lcov.info');
     159           2 :         final lcovFile = File(lcovPath);
     160             : 
     161           1 :         if (collectCoverage && lcovFile.existsSync()) {
     162           1 :           await lcovFile.delete();
     163             :         }
     164             : 
     165           1 :         void noop(String? _) {}
     166           6 :         final target = DirectoryGeneratorTarget(Directory(p.normalize(cwd)));
     167           6 :         final workingDirectory = target.dir.absolute.path;
     168             : 
     169           2 :         stdout?.call(
     170           4 :           'Running "flutter test" in ${p.dirname(workingDirectory)}...\n',
     171             :         );
     172             : 
     173          12 :         if (!Directory(p.join(target.dir.absolute.path, 'test')).existsSync()) {
     174           2 :           stdout?.call(
     175           8 :             'No test folder found in ${target.dir.absolute.path}\n',
     176             :           );
     177           2 :           return ExitCode.success.code;
     178             :         }
     179             : 
     180             :         if (randomSeed != null) {
     181           1 :           stdout?.call(
     182           1 :             '''Shuffling test order with --test-randomize-ordering-seed=$randomSeed\n''',
     183             :           );
     184             :         }
     185             : 
     186             :         if (optimizePerformance) {
     187           1 :           final optimizationProgress = logger.progress('Optimizing tests');
     188             :           try {
     189           2 :             final generator = await buildGenerator(testOptimizerBundle);
     190           1 :             var vars = <String, dynamic>{'package-root': workingDirectory};
     191           2 :             await generator.hooks.preGen(
     192             :               vars: vars,
     193           1 :               onVarsChanged: (v) => vars = v,
     194             :               workingDirectory: workingDirectory,
     195             :             );
     196           1 :             await generator.generate(
     197             :               target,
     198             :               vars: vars,
     199             :               fileConflictResolution: FileConflictResolution.overwrite,
     200             :             );
     201             :           } finally {
     202           1 :             optimizationProgress.complete();
     203             :           }
     204             :         }
     205           1 :         return _overrideAnsiOutput(
     206             :           forceAnsi,
     207           2 :           () => _flutterTest(
     208             :             cwd: cwd,
     209             :             collectCoverage: collectCoverage,
     210             :             testRunner: testRunner,
     211           1 :             arguments: [
     212           1 :               ...?arguments,
     213           1 :               if (randomSeed != null) ...[
     214             :                 '--test-randomize-ordering-seed',
     215             :                 randomSeed,
     216             :               ],
     217           1 :               if (optimizePerformance) p.join('test', _testOptimizerFileName),
     218             :             ],
     219             :             stdout: stdout ?? noop,
     220             :             stderr: stderr ?? noop,
     221           2 :           ).whenComplete(() async {
     222             :             if (optimizePerformance) {
     223           2 :               File(p.join(cwd, 'test', _testOptimizerFileName))
     224           1 :                   .delete()
     225           1 :                   .ignore();
     226             :             }
     227             : 
     228             :             if (collectCoverage) {
     229           2 :               assert(lcovFile.existsSync(), 'coverage/lcov.info must exist');
     230             :             }
     231             : 
     232             :             if (minCoverage != null) {
     233           1 :               final records = await Parser.parse(lcovPath);
     234           1 :               final coverageMetrics = _CoverageMetrics.fromLcovRecords(
     235             :                 records,
     236             :                 excludeFromCoverage,
     237             :               );
     238           1 :               final coverage = coverageMetrics.percentage;
     239             : 
     240           2 :               if (coverage < minCoverage) throw MinCoverageNotMet(coverage);
     241             :             }
     242             :           }),
     243             :         );
     244             :       },
     245             :       cwd: cwd,
     246             :       recursive: recursive,
     247             :       ignore: ignore,
     248             :     );
     249             :   }
     250             : 
     251           1 :   static T _overrideAnsiOutput<T>(bool? enableAnsiOutput, T Function() body) =>
     252             :       enableAnsiOutput == null
     253           1 :           ? body.call()
     254           1 :           : overrideAnsiOutput(enableAnsiOutput, body);
     255             : }
     256             : 
     257             : /// Ensures all git dependencies are reachable for the pubspec
     258             : /// located in the [cwd].
     259             : ///
     260             : /// If any git dependencies are unreachable,
     261             : /// an [UnreachableGitDependency] is thrown.
     262           5 : Future<void> _verifyGitDependencies(
     263             :   String cwd, {
     264             :   required Logger logger,
     265             : }) async {
     266           5 :   final pubspec = Pubspec.parse(
     267          15 :     await File(p.join(cwd, 'pubspec.yaml')).readAsString(),
     268             :   );
     269             : 
     270           5 :   final dependencies = pubspec.dependencies;
     271           5 :   final devDependencies = pubspec.devDependencies;
     272           5 :   final dependencyOverrides = pubspec.dependencyOverrides;
     273           5 :   final gitDependencies = [
     274           5 :     ...dependencies.entries,
     275           5 :     ...devDependencies.entries,
     276           5 :     ...dependencyOverrides.entries,
     277             :   ]
     278           8 :       .where((entry) => entry.value is GitDependency)
     279           7 :       .map((entry) => entry.value)
     280           5 :       .cast<GitDependency>()
     281           5 :       .toList();
     282             : 
     283           5 :   await Future.wait(
     284           5 :     gitDependencies.map(
     285           2 :       (dependency) => Git.reachable(
     286           1 :         dependency.url,
     287             :         logger: logger,
     288             :       ),
     289             :     ),
     290             :   );
     291             : }
     292             : 
     293             : /// Run a command on directories with a `pubspec.yaml`.
     294           9 : Future<List<T>> _runCommand<T>({
     295             :   required Future<T> Function(String cwd) cmd,
     296             :   required String cwd,
     297             :   required bool recursive,
     298             :   required Set<String> ignore,
     299             : }) async {
     300             :   if (!recursive) {
     301          14 :     final pubspec = File(p.join(cwd, 'pubspec.yaml'));
     302           9 :     if (!pubspec.existsSync()) throw PubspecNotFound();
     303             : 
     304          14 :     return [await cmd(cwd)];
     305             :   }
     306             : 
     307           4 :   final processes = _Cmd.runWhere<T>(
     308          16 :     run: (entity) => cmd(entity.parent.path),
     309          12 :     where: (entity) => !ignore.excludes(entity) && _isPubspec(entity),
     310             :     cwd: cwd,
     311             :   );
     312             : 
     313           6 :   if (processes.isEmpty) throw PubspecNotFound();
     314             : 
     315           4 :   final results = <T>[];
     316           8 :   for (final process in processes) {
     317           4 :     results.add(await process);
     318             :   }
     319             :   return results;
     320             : }
     321             : 
     322           1 : Future<int> _flutterTest({
     323             :   required void Function(String) stdout,
     324             :   required void Function(String) stderr,
     325             :   String cwd = '.',
     326             :   bool collectCoverage = false,
     327             :   List<String>? arguments,
     328             :   FlutterTestRunner testRunner = flutterTest,
     329             : }) {
     330             :   const clearLine = '\u001B[2K\r';
     331             : 
     332           1 :   final completer = Completer<int>();
     333           1 :   final suites = <int, TestSuite>{};
     334           1 :   final groups = <int, TestGroup>{};
     335           1 :   final tests = <int, Test>{};
     336           1 :   final failedTestErrorMessages = <String, List<String>>{};
     337             : 
     338             :   var successCount = 0;
     339             :   var skipCount = 0;
     340             : 
     341           1 :   String computeStats() {
     342           1 :     final passingTests = successCount.formatSuccess();
     343             :     final failingTests =
     344           5 :         failedTestErrorMessages.values.expand((e) => e).length.formatFailure();
     345           1 :     final skippedTests = skipCount.formatSkipped();
     346           1 :     final result = [passingTests, failingTests, skippedTests]
     347           3 :       ..removeWhere((element) => element.isEmpty);
     348           1 :     return result.join(' ');
     349             :   }
     350             : 
     351             :   final timerSubscription =
     352           3 :       Stream.periodic(const Duration(seconds: 1), (_) => _).listen(
     353           1 :     (tick) {
     354           1 :       if (completer.isCompleted) return;
     355           2 :       final timeElapsed = Duration(seconds: tick).formatted();
     356           2 :       stdout('$clearLine$timeElapsed ...');
     357             :     },
     358             :   );
     359             : 
     360             :   late final StreamSubscription<TestEvent> subscription;
     361           1 :   subscription = testRunner(
     362             :     workingDirectory: cwd,
     363           1 :     arguments: [
     364           1 :       if (collectCoverage) '--coverage',
     365           1 :       ...?arguments,
     366             :     ],
     367             :     runInShell: true,
     368           1 :   ).listen(
     369           1 :     (event) {
     370           2 :       if (event.shouldCancelTimer()) timerSubscription.cancel();
     371           5 :       if (event is SuiteTestEvent) suites[event.suite.id] = event.suite;
     372           5 :       if (event is GroupTestEvent) groups[event.group.id] = event.group;
     373           5 :       if (event is TestStartEvent) tests[event.test.id] = event.test;
     374             : 
     375           1 :       if (event is MessageTestEvent) {
     376           2 :         if (event.message.startsWith('Skip:')) {
     377           4 :           stdout('$clearLine${lightYellow.wrap(event.message)}\n');
     378           2 :         } else if (event.message.contains('EXCEPTION')) {
     379           3 :           stderr('$clearLine${event.message}');
     380             :         } else {
     381           3 :           stdout('$clearLine${event.message}\n');
     382             :         }
     383             :       }
     384             : 
     385           1 :       if (event is ErrorTestEvent) {
     386           3 :         stderr('$clearLine${event.error}');
     387             : 
     388           3 :         if (event.stackTrace.trim().isNotEmpty) {
     389           3 :           stderr('$clearLine${event.stackTrace}');
     390             :         }
     391             : 
     392           2 :         final test = tests[event.testID]!;
     393           2 :         final suite = suites[test.suiteID]!;
     394           1 :         final prefix = event.isFailure ? '[FAILED]' : '[ERROR]';
     395             : 
     396           1 :         final optimizationApplied = _isOptimizationApplied(suite);
     397             : 
     398           1 :         var testPath = suite.path!;
     399           1 :         var testName = test.name;
     400             : 
     401             :         // When there is a test error before any group is computed, it means
     402             :         // that there is an error when compiling the test optimizer file.
     403           1 :         if (optimizationApplied && groups.isNotEmpty) {
     404           1 :           final topGroupName = _topGroupName(test, groups)!;
     405             : 
     406           1 :           testPath = testPath.replaceFirst(
     407             :             _testOptimizerFileName,
     408             :             topGroupName,
     409             :           );
     410             : 
     411           2 :           testName = testName.replaceFirst(topGroupName, '').trim();
     412             :         }
     413             : 
     414           1 :         final relativeTestPath = p.relative(testPath, from: cwd);
     415           2 :         failedTestErrorMessages[relativeTestPath] = [
     416           2 :           ...failedTestErrorMessages[relativeTestPath] ?? [],
     417           1 :           '$prefix $testName',
     418             :         ];
     419             :       }
     420             : 
     421           1 :       if (event is TestDoneEvent) {
     422           1 :         if (event.hidden) return;
     423             : 
     424           2 :         final test = tests[event.testID]!;
     425           2 :         final suite = suites[test.suiteID]!;
     426           1 :         final optimizationApplied = _isOptimizationApplied(suite);
     427             : 
     428           1 :         var testPath = suite.path!;
     429           1 :         var testName = test.name;
     430             : 
     431             :         if (optimizationApplied) {
     432           1 :           final firstGroupName = _topGroupName(test, groups) ?? '';
     433           1 :           testPath = testPath.replaceFirst(
     434             :             _testOptimizerFileName,
     435             :             firstGroupName,
     436             :           );
     437           2 :           testName = testName.replaceFirst(firstGroupName, '').trim();
     438             :         }
     439             : 
     440           1 :         if (event.skipped) {
     441           1 :           stdout(
     442           3 :             '''$clearLine${lightYellow.wrap('$testName $testPath (SKIPPED)')}\n''',
     443             :           );
     444           1 :           skipCount++;
     445           2 :         } else if (event.result == TestResult.success) {
     446           1 :           successCount++;
     447             :         } else {
     448           2 :           stderr('$clearLine$testName $testPath (FAILED)');
     449             :         }
     450             : 
     451           3 :         final timeElapsed = Duration(milliseconds: event.time).formatted();
     452           1 :         final stats = computeStats();
     453           2 :         final truncatedTestName = testName.toSingleLine().truncated(
     454           6 :               _lineLength - (timeElapsed.length + stats.length + 2),
     455             :             );
     456           2 :         stdout('''$clearLine$timeElapsed $stats: $truncatedTestName''');
     457             :       }
     458             : 
     459           1 :       if (event is DoneTestEvent) {
     460           3 :         final timeElapsed = Duration(milliseconds: event.time).formatted();
     461           1 :         final stats = computeStats();
     462           1 :         final summary = event.success ?? false
     463           1 :             ? lightGreen.wrap('All tests passed!')!
     464           1 :             : lightRed.wrap('Some tests failed.')!;
     465             : 
     466           3 :         stdout('$clearLine${darkGray.wrap(timeElapsed)} $stats: $summary\n');
     467             : 
     468           2 :         if (event.success != true) {
     469             :           assert(
     470           2 :             failedTestErrorMessages.isNotEmpty,
     471             :             'Invalid state: test event report as failed but no failed tests '
     472             :             'were gathered',
     473             :           );
     474           1 :           final title = styleBold.wrap('Failing Tests:');
     475             : 
     476           2 :           final lines = StringBuffer('$clearLine$title\n');
     477             :           for (final testSuiteErrorMessages
     478           2 :               in failedTestErrorMessages.entries) {
     479           3 :             lines.writeln('$clearLine - ${testSuiteErrorMessages.key} ');
     480             : 
     481           2 :             for (final errorMessage in testSuiteErrorMessages.value) {
     482           2 :               lines.writeln('$clearLine \t- $errorMessage');
     483             :             }
     484             :           }
     485             : 
     486           2 :           stderr(lines.toString());
     487             :         }
     488             :       }
     489             : 
     490           1 :       if (event is ExitTestEvent) {
     491           1 :         if (completer.isCompleted) return;
     492           1 :         subscription.cancel();
     493           1 :         completer.complete(
     494           3 :           event.exitCode == ExitCode.success.code
     495           1 :               ? ExitCode.success.code
     496           1 :               : ExitCode.unavailable.code,
     497             :         );
     498             :       }
     499             :     },
     500           1 :     onError: (Object error, StackTrace stackTrace) {
     501           2 :       stderr('$clearLine$error');
     502           2 :       stderr('$clearLine$stackTrace');
     503             :     },
     504             :   );
     505             : 
     506           1 :   return completer.future;
     507             : }
     508             : 
     509           1 : bool _isOptimizationApplied(TestSuite suite) =>
     510           2 :     suite.path?.contains(_testOptimizerFileName) ?? false;
     511             : 
     512           2 : String? _topGroupName(Test test, Map<int, TestGroup> groups) => test.groupIDs
     513           4 :     .map((groupID) => groups[groupID]?.name)
     514           3 :     .firstWhereOrNull((groupName) => groupName?.isNotEmpty ?? false);
     515             : 
     516           3 : final int _lineLength = () {
     517             :   try {
     518           2 :     return stdout.terminalColumns;
     519           1 :   } on StdoutException {
     520             :     return 80;
     521             :   }
     522           1 : }();
     523             : 
     524             : // The extension is intended to be unnamed, but it's not possible due to
     525             : // an issue with Dart SDK 2.18.0.
     526             : //
     527             : // Once the min Dart SDK is bumped, this extension can be unnamed again.
     528             : extension _TestEvent on TestEvent {
     529           1 :   bool shouldCancelTimer() {
     530             :     final event = this;
     531           1 :     if (event is MessageTestEvent) return true;
     532           1 :     if (event is ErrorTestEvent) return true;
     533           1 :     if (event is DoneTestEvent) return true;
     534           2 :     if (event is TestDoneEvent) return !event.hidden;
     535             :     return false;
     536             :   }
     537             : }
     538             : 
     539             : extension on Duration {
     540           1 :   String formatted() {
     541           3 :     String twoDigits(int n) => n.toString().padLeft(2, '0');
     542           3 :     final twoDigitMinutes = twoDigits(inMinutes.remainder(60));
     543           3 :     final twoDigitSeconds = twoDigits(inSeconds.remainder(60));
     544           2 :     return darkGray.wrap('$twoDigitMinutes:$twoDigitSeconds')!;
     545             :   }
     546             : }
     547             : 
     548             : extension on int {
     549           1 :   String formatSuccess() {
     550           3 :     return this > 0 ? lightGreen.wrap('+$this')! : '';
     551             :   }
     552             : 
     553           1 :   String formatFailure() {
     554           3 :     return this > 0 ? lightRed.wrap('-$this')! : '';
     555             :   }
     556             : 
     557           1 :   String formatSkipped() {
     558           3 :     return this > 0 ? lightYellow.wrap('~$this')! : '';
     559             :   }
     560             : }
     561             : 
     562             : extension on String {
     563           1 :   String truncated(int maxLength) {
     564           2 :     if (length <= maxLength) return this;
     565           5 :     final truncated = substring(length - maxLength, length).trim();
     566           1 :     return '...$truncated';
     567             :   }
     568             : 
     569           1 :   String toSingleLine() {
     570           3 :     return replaceAll('\n', '').replaceAll(RegExp(r'\s\s+'), ' ');
     571             :   }
     572             : }

Generated by: LCOV version 1.16