Line data Source code
1 : import 'dart:math';
2 :
3 : import 'package:args/args.dart';
4 : import 'package:args/command_runner.dart';
5 : import 'package:mason/mason.dart';
6 : import 'package:meta/meta.dart';
7 : import 'package:path/path.dart' as path;
8 : import 'package:universal_io/io.dart';
9 : import 'package:very_good_cli/src/cli/cli.dart';
10 :
11 : /// Signature for the [Flutter.installed] method.
12 : typedef FlutterInstalledCommand = Future<bool> Function({
13 : required Logger logger,
14 : });
15 :
16 : /// Signature for the [Flutter.test] method.
17 : typedef FlutterTestCommand = Future<List<int>> Function({
18 : required Logger logger,
19 : String cwd,
20 : bool recursive,
21 : bool collectCoverage,
22 : bool optimizePerformance,
23 : double? minCoverage,
24 : String? excludeFromCoverage,
25 : String? randomSeed,
26 : bool? forceAnsi,
27 : List<String>? arguments,
28 : void Function(String)? stdout,
29 : void Function(String)? stderr,
30 : });
31 :
32 : /// {@template test_command}
33 : /// `very_good test` command for running tests.
34 : /// {@endtemplate}
35 : class TestCommand extends Command<int> {
36 : /// {@macro test_command}
37 15 : TestCommand({
38 : required Logger logger,
39 : @visibleForTesting FlutterInstalledCommand? flutterInstalled,
40 : @visibleForTesting FlutterTestCommand? flutterTest,
41 : }) : _logger = logger,
42 : _flutterInstalled = flutterInstalled ?? Flutter.installed,
43 : _flutterTest = flutterTest ?? Flutter.test {
44 15 : argParser
45 15 : ..addFlag(
46 : 'coverage',
47 : help: 'Whether to collect coverage information.',
48 : negatable: false,
49 : )
50 15 : ..addFlag(
51 : 'recursive',
52 : abbr: 'r',
53 : help: 'Run tests recursively for all nested packages.',
54 : negatable: false,
55 : )
56 15 : ..addFlag(
57 : 'optimization',
58 : defaultsTo: true,
59 : help: 'Whether to apply optimizations for test performance.',
60 : )
61 15 : ..addOption(
62 : 'concurrency',
63 : abbr: 'j',
64 : defaultsTo: '4',
65 : help: 'The number of concurrent test suites run.',
66 : )
67 15 : ..addOption(
68 : 'tags',
69 : abbr: 't',
70 : help: 'Run only tests associated with the specified tags.',
71 : )
72 15 : ..addOption(
73 : 'exclude-coverage',
74 : help: 'A glob which will be used to exclude files that match from the '
75 : 'coverage.',
76 : )
77 15 : ..addOption(
78 : 'exclude-tags',
79 : abbr: 'x',
80 : help: 'Run only tests that do not have the specified tags.',
81 : )
82 15 : ..addOption(
83 : 'min-coverage',
84 : help: 'Whether to enforce a minimum coverage percentage.',
85 : )
86 15 : ..addOption(
87 : 'test-randomize-ordering-seed',
88 : help: 'The seed to randomize the execution order of test cases '
89 : 'within test files.',
90 : )
91 15 : ..addFlag(
92 : 'update-goldens',
93 : help: 'Whether "matchesGoldenFile()" calls within your test methods '
94 : 'should update the golden files.',
95 : negatable: false,
96 : )
97 15 : ..addFlag(
98 : 'force-ansi',
99 : defaultsTo: null,
100 : help: 'Whether to force ansi output. If not specified, '
101 : 'it will maintain the default behavior based on stdout and stderr.',
102 : negatable: false,
103 : )
104 15 : ..addMultiOption(
105 : 'dart-define',
106 : help: 'Additional key-value pairs that will be available as constants '
107 : 'from the String.fromEnvironment, bool.fromEnvironment, '
108 : 'int.fromEnvironment, and double.fromEnvironment constructors. '
109 : 'Multiple defines can be passed by repeating '
110 : '"--dart-define" multiple times.',
111 : valueHelp: 'foo=bar',
112 : );
113 : }
114 :
115 : final Logger _logger;
116 : final FlutterInstalledCommand _flutterInstalled;
117 : final FlutterTestCommand _flutterTest;
118 :
119 2 : @override
120 : String get description => 'Run tests in a Dart or Flutter project.';
121 :
122 15 : @override
123 : String get name => 'test';
124 :
125 : /// [ArgResults] which can be overridden for testing.
126 : @visibleForTesting
127 : ArgResults? argResultOverrides;
128 :
129 3 : ArgResults get _argResults => argResultOverrides ?? argResults!;
130 :
131 1 : @override
132 : Future<int> run() async {
133 4 : final targetPath = path.normalize(Directory.current.absolute.path);
134 2 : final pubspec = File(path.join(targetPath, 'pubspec.yaml'));
135 2 : final recursive = _argResults['recursive'] as bool;
136 :
137 1 : if (!recursive && !pubspec.existsSync()) {
138 2 : _logger.err(
139 : '''
140 : Could not find a pubspec.yaml in $targetPath.
141 1 : This command should be run from the root of your Flutter project.''',
142 : );
143 1 : return ExitCode.noInput.code;
144 : }
145 :
146 2 : final concurrency = _argResults['concurrency'] as String;
147 2 : final collectCoverage = _argResults['coverage'] as bool;
148 1 : final minCoverage = double.tryParse(
149 2 : _argResults['min-coverage'] as String? ?? '',
150 : );
151 2 : final excludeTags = _argResults['exclude-tags'] as String?;
152 2 : final tags = _argResults['tags'] as String?;
153 3 : final isFlutterInstalled = await _flutterInstalled(logger: _logger);
154 2 : final excludeFromCoverage = _argResults['exclude-coverage'] as String?;
155 : final randomOrderingSeed =
156 2 : _argResults['test-randomize-ordering-seed'] as String?;
157 1 : final randomSeed = randomOrderingSeed == 'random'
158 3 : ? Random().nextInt(4294967295).toString()
159 : : randomOrderingSeed;
160 2 : final optimizePerformance = _argResults['optimization'] as bool;
161 2 : final updateGoldens = _argResults['update-goldens'] as bool;
162 2 : final forceAnsi = _argResults['force-ansi'] as bool?;
163 2 : final dartDefine = _argResults['dart-define'] as List<String>?;
164 2 : final rest = _argResults.rest;
165 :
166 : if (isFlutterInstalled) {
167 : try {
168 2 : final results = await _flutterTest(
169 : optimizePerformance: optimizePerformance &&
170 1 : !_isTargettingTestFiles(rest) &&
171 : !updateGoldens,
172 : recursive: recursive,
173 1 : logger: _logger,
174 2 : stdout: _logger.write,
175 2 : stderr: _logger.err,
176 : collectCoverage: collectCoverage || minCoverage != null,
177 : minCoverage: minCoverage,
178 : excludeFromCoverage: excludeFromCoverage,
179 : randomSeed: randomSeed,
180 : forceAnsi: forceAnsi,
181 1 : arguments: [
182 1 : if (excludeTags != null) ...['-x', excludeTags],
183 1 : if (tags != null) ...['-t', tags],
184 1 : if (updateGoldens) '--update-goldens',
185 : if (dartDefine != null)
186 2 : for (final value in dartDefine) '--dart-define=$value',
187 1 : ...['-j', concurrency],
188 1 : '--no-pub',
189 1 : ...rest,
190 : ],
191 : );
192 4 : if (results.any((code) => code != ExitCode.success.code)) {
193 1 : return ExitCode.unavailable.code;
194 : }
195 1 : } on MinCoverageNotMet catch (e) {
196 2 : _logger.err(
197 4 : '''Expected coverage >= ${minCoverage!.toStringAsFixed(2)}% but actual is ${e.coverage.toStringAsFixed(2)}%.''',
198 : );
199 1 : return ExitCode.unavailable.code;
200 : } catch (error) {
201 3 : _logger.err('$error');
202 1 : return ExitCode.unavailable.code;
203 : }
204 : }
205 1 : return ExitCode.success.code;
206 : }
207 : }
208 :
209 : /// Determines whether the user is targetting test files or not.
210 : ///
211 : /// The user can only target test files by using the `--` option terminator.
212 : /// The additional options after the `--` are passed to the test runner which
213 : /// allows the user to target specific test files or directories.
214 : ///
215 : /// The heuristics used to determine if the user is not targetting test files
216 : /// are:
217 : /// * No [rest] arguments are passed.
218 : /// * All [rest] arguments are options (i.e. they do not start with `-`).
219 : ///
220 : /// See also:
221 : /// * [What does -- mean in Shell?](https://www.cyberciti.biz/faq/what-does-double-dash-mean-in-ssh-command/)
222 1 : bool _isTargettingTestFiles(List<String> rest) {
223 1 : if (rest.isEmpty) {
224 : return false;
225 : }
226 :
227 4 : return rest.where((arg) => !arg.startsWith('-')).isNotEmpty;
228 : }
|