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