LCOV - code coverage report
Current view: top level - src/cli - flutter_cli.dart (source / functions) Hit Total Coverage
Test: lcov.info Lines: 235 235 100.0 %
Date: 2024-03-25 10:36:11 Functions: 0 0 -

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

Generated by: LCOV version 1.15