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 : }
|