1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:async';
6import 'dart:convert';
7import 'dart:core' hide print;
8import 'dart:io' as system show exit;
9import 'dart:io' hide exit;
10import 'dart:math' as math;
11
12import 'package:analyzer/dart/analysis/results.dart';
13import 'package:analyzer/dart/ast/ast.dart';
14import 'package:analyzer/source/line_info.dart';
15import 'package:collection/collection.dart';
16import 'package:file/file.dart' as fs;
17import 'package:file/local.dart';
18import 'package:meta/meta.dart';
19import 'package:path/path.dart' as path;
20
21import 'run_command.dart';
22import 'tool_subsharding.dart';
23
24typedef ShardRunner = Future<void> Function();
25
26/// A function used to validate the output of a test.
27///
28/// If the output matches expectations, the function shall return null.
29///
30/// If the output does not match expectations, the function shall return an
31/// appropriate error message.
32typedef OutputChecker = String? Function(CommandResult);
33
34const Duration _quietTimeout = Duration(
35 minutes: 10,
36); // how long the output should be hidden between calls to printProgress before just being verbose
37
38// If running from LUCI set to False.
39final bool isLuci = Platform.environment['LUCI_CI'] == 'True';
40final bool hasColor = stdout.supportsAnsiEscapes && !isLuci;
41final bool _isRandomizationOff =
42 bool.tryParse(Platform.environment['TEST_RANDOMIZATION_OFF'] ?? '') ?? false;
43
44final String bold = hasColor ? '\x1B[1m' : ''; // shard titles
45final String red = hasColor ? '\x1B[31m' : ''; // errors
46final String green = hasColor ? '\x1B[32m' : ''; // section titles, commands
47final String yellow = hasColor
48 ? '\x1B[33m'
49 : ''; // indications that a test was skipped (usually renders orange or brown)
50final String cyan = hasColor ? '\x1B[36m' : ''; // paths
51final String reverse = hasColor ? '\x1B[7m' : ''; // clocks
52final String gray = hasColor
53 ? '\x1B[30m'
54 : ''; // subtle decorative items (usually renders as dark gray)
55final String white = hasColor ? '\x1B[37m' : ''; // last log line (usually renders as light gray)
56final String reset = hasColor ? '\x1B[0m' : '';
57
58final String exe = Platform.isWindows ? '.exe' : '';
59final String bat = Platform.isWindows ? '.bat' : '';
60final String flutterRoot = path.dirname(path.dirname(path.dirname(path.fromUri(Platform.script))));
61final String flutter = path.join(flutterRoot, 'bin', 'flutter$bat');
62final String dart = path.join(flutterRoot, 'bin', 'cache', 'dart-sdk', 'bin', 'dart$exe');
63final String pubCache = path.join(flutterRoot, '.pub-cache');
64final String engineVersionFile = path.join(flutterRoot, 'bin', 'cache', 'engine.stamp');
65final String luciBotId = Platform.environment['SWARMING_BOT_ID'] ?? '';
66final bool runningInDartHHHBot =
67 luciBotId.startsWith('luci-dart-') || luciBotId.startsWith('dart-tests-');
68
69const String kShardKey = 'SHARD';
70const String kSubshardKey = 'SUBSHARD';
71const String kTestHarnessShardName = 'test_harness_tests';
72
73/// Environment variables to override the local engine when running `pub test`,
74/// if such flags are provided to `test.dart`.
75final Map<String, String> localEngineEnv = <String, String>{};
76
77/// The arguments to pass to `flutter test` (typically the local engine
78/// configuration) -- prefilled with the arguments passed to test.dart.
79final List<String> flutterTestArgs = <String>[];
80
81/// Whether execution should be simulated for debugging purposes.
82///
83/// When `true`, calls to [runCommand] print to [io.stdout] instead of running
84/// the process. This is useful for determining what an invocation of `test.dart`
85/// _might_ due if not invoked with `--dry-run`, or otherwise determine what the
86/// different test shards and sub-shards are configured as.
87bool get dryRun => _dryRun ?? false;
88
89/// Switches [dryRun] to `true`.
90///
91/// Expected to be called at most once during execution of a process.
92void enableDryRun() {
93 if (_dryRun != null) {
94 throw StateError('Should only be called at most once');
95 }
96 _dryRun = true;
97}
98
99bool? _dryRun;
100
101const int kESC = 0x1B;
102const int kOpenSquareBracket = 0x5B;
103const int kCSIParameterRangeStart = 0x30;
104const int kCSIParameterRangeEnd = 0x3F;
105const int kCSIIntermediateRangeStart = 0x20;
106const int kCSIIntermediateRangeEnd = 0x2F;
107const int kCSIFinalRangeStart = 0x40;
108const int kCSIFinalRangeEnd = 0x7E;
109
110int get terminalColumns {
111 try {
112 return stdout.terminalColumns;
113 } catch (e) {
114 return 40;
115 }
116}
117
118String get redLine {
119 if (hasColor) {
120 return '$red${'━' * terminalColumns}$reset';
121 }
122 return '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
123}
124
125String get clock {
126 final DateTime now = DateTime.now();
127 return '$reverse▌'
128 '${now.hour.toString().padLeft(2, "0")}:'
129 '${now.minute.toString().padLeft(2, "0")}:'
130 '${now.second.toString().padLeft(2, "0")}'
131 '▐$reset';
132}
133
134String prettyPrintDuration(Duration duration) {
135 String result = '';
136 final int minutes = duration.inMinutes;
137 if (minutes > 0) {
138 result += '${minutes}min ';
139 }
140 final int seconds = duration.inSeconds - minutes * 60;
141 final int milliseconds = duration.inMilliseconds - (seconds * 1000 + minutes * 60 * 1000);
142 result += '$seconds.${milliseconds.toString().padLeft(3, "0")}s';
143 return result;
144}
145
146typedef PrintCallback = void Function(Object? line);
147typedef VoidCallback = void Function();
148
149// Allow print() to be overridden, for tests.
150//
151// Files that import this library should not import `print` from dart:core
152// and should not use dart:io's `stdout` or `stderr`.
153//
154// By default this hides log lines between `printProgress` calls unless a
155// timeout expires or anything calls `foundError`.
156//
157// Also used to implement `--verbose` in test.dart.
158PrintCallback print = _printQuietly;
159
160// Called by foundError and used to implement `--abort-on-error` in test.dart.
161VoidCallback? onError;
162
163bool get hasError => _hasError;
164bool _hasError = false;
165
166List<List<String>> _errorMessages = <List<String>>[];
167
168final List<String> _pendingLogs = <String>[];
169Timer? _hideTimer; // When this is null, the output is verbose.
170
171void foundError(List<String> messages) {
172 if (dryRun) {
173 printProgress(messages.join('\n'));
174 return;
175 }
176 assert(messages.isNotEmpty);
177 // Make the error message easy to notice in the logs by
178 // wrapping it in a red box.
179 final int width = math.max(15, (hasColor ? terminalColumns : 80) - 1);
180 final String title = 'ERROR #${_errorMessages.length + 1}';
181 print('$red╔═╡$bold$title$reset$red╞═${"═" * (width - 4 - title.length)}');
182 for (final String message in messages.expand((String line) => line.split('\n'))) {
183 print('$red$reset $message');
184 }
185 print('$red${"═" * width}');
186 // Normally, "print" actually prints to the log. To make the errors visible,
187 // and to include useful context, print the entire log up to this point, and
188 // clear it. Subsequent messages will continue to not be logged until there is
189 // another error.
190 _pendingLogs.forEach(_printLoudly);
191 _pendingLogs.clear();
192 _errorMessages.add(messages);
193 _hasError = true;
194 onError?.call();
195}
196
197@visibleForTesting
198void resetErrorStatus() {
199 _hasError = false;
200 _errorMessages.clear();
201 _pendingLogs.clear();
202 _hideTimer?.cancel();
203 _hideTimer = null;
204}
205
206Never reportSuccessAndExit(String message) {
207 _hideTimer?.cancel();
208 _hideTimer = null;
209 print('$clock $message$reset');
210 system.exit(0);
211}
212
213Never reportErrorsAndExit(String message) {
214 _hideTimer?.cancel();
215 _hideTimer = null;
216 print('$clock $message$reset');
217 print(redLine);
218 print('${red}The error messages reported above are repeated here:$reset');
219 final bool printSeparators = _errorMessages.any((List<String> messages) => messages.length > 1);
220 if (printSeparators) {
221 print(' -- This line intentionally left blank -- ');
222 }
223 for (int index = 0; index < _errorMessages.length * 2 - 1; index += 1) {
224 if (index.isEven) {
225 _errorMessages[index ~/ 2].forEach(print);
226 } else if (printSeparators) {
227 print(' -- This line intentionally left blank -- ');
228 }
229 }
230 print(redLine);
231 print('You may find the errors by searching for "╡ERROR #" in the logs.');
232 system.exit(1);
233}
234
235void printProgress(String message) {
236 _pendingLogs.clear();
237 _hideTimer?.cancel();
238 _hideTimer = null;
239 print('$clock $message$reset');
240 if (hasColor) {
241 // This sets up a timer to switch to verbose mode when the tests take too long,
242 // so that if a test hangs we can see the logs.
243 // (This is only supported with a color terminal. When the terminal doesn't
244 // support colors, the scripts just print everything verbosely, that way in
245 // CI there's nothing hidden.)
246 _hideTimer = Timer(_quietTimeout, () {
247 _hideTimer = null;
248 _pendingLogs.forEach(_printLoudly);
249 _pendingLogs.clear();
250 });
251 }
252}
253
254final Pattern _lineBreak = RegExp(r'[\r\n]');
255
256void _printQuietly(Object? message) {
257 // The point of this function is to avoid printing its output unless the timer
258 // has gone off in which case the function assumes verbose mode is active and
259 // prints everything. To show that progress is still happening though, rather
260 // than showing nothing at all, it instead shows the last line of output and
261 // keeps overwriting it. To do this in color mode, carefully measures the line
262 // of text ignoring color codes, which is what the parser below does.
263 if (_hideTimer != null) {
264 _pendingLogs.add(message.toString());
265 String line = '$message'.trimRight();
266 final int start = line.lastIndexOf(_lineBreak) + 1;
267 int index = start;
268 int length = 0;
269 while (index < line.length && length < terminalColumns) {
270 if (line.codeUnitAt(index) == kESC) {
271 // 0x1B
272 index += 1;
273 if (index < line.length && line.codeUnitAt(index) == kOpenSquareBracket) {
274 // 0x5B, [
275 // That was the start of a CSI sequence.
276 index += 1;
277 while (index < line.length &&
278 line.codeUnitAt(index) >= kCSIParameterRangeStart &&
279 line.codeUnitAt(index) <= kCSIParameterRangeEnd) {
280 // 0x30..0x3F
281 index += 1; // ...parameter bytes...
282 }
283 while (index < line.length &&
284 line.codeUnitAt(index) >= kCSIIntermediateRangeStart &&
285 line.codeUnitAt(index) <= kCSIIntermediateRangeEnd) {
286 // 0x20..0x2F
287 index += 1; // ...intermediate bytes...
288 }
289 if (index < line.length &&
290 line.codeUnitAt(index) >= kCSIFinalRangeStart &&
291 line.codeUnitAt(index) <= kCSIFinalRangeEnd) {
292 // 0x40..0x7E
293 index += 1; // ...final byte.
294 }
295 }
296 } else {
297 index += 1;
298 length += 1;
299 }
300 }
301 line = line.substring(start, index);
302 if (line.isNotEmpty) {
303 stdout.write('\r\x1B[2K$white$line$reset');
304 }
305 } else {
306 _printLoudly('$message');
307 }
308}
309
310void _printLoudly(String message) {
311 if (hasColor) {
312 // Overwrite the last line written by _printQuietly.
313 stdout.writeln('\r\x1B[2K$reset${message.trimRight()}');
314 } else {
315 stdout.writeln(message);
316 }
317}
318
319// THE FOLLOWING CODE IS A VIOLATION OF OUR STYLE GUIDE
320// BECAUSE IT INTRODUCES A VERY FLAKY RACE CONDITION
321// https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#never-check-if-a-port-is-available-before-using-it-never-add-timeouts-and-other-race-conditions
322// DO NOT USE THE FOLLOWING FUNCTIONS
323// DO NOT WRITE CODE LIKE THE FOLLOWING FUNCTIONS
324// https://github.com/flutter/flutter/issues/109474
325
326int _portCounter = 8080;
327
328/// Finds the next available local port.
329Future<int> findAvailablePortAndPossiblyCauseFlakyTests() async {
330 while (!await _isPortAvailable(_portCounter)) {
331 _portCounter += 1;
332 }
333 return _portCounter++;
334}
335
336Future<bool> _isPortAvailable(int port) async {
337 try {
338 final RawSocket socket = await RawSocket.connect('localhost', port);
339 socket.shutdown(SocketDirection.both);
340 await socket.close();
341 return false;
342 } on SocketException {
343 return true;
344 }
345}
346
347String locationInFile(ResolvedUnitResult unit, AstNode node, String workingDirectory) {
348 return '${path.relative(path.relative(unit.path, from: workingDirectory))}:${unit.lineInfo.getLocation(node.offset).lineNumber}';
349}
350
351/// Whether the given [AstNode] within the `compilationUnit` is under the effect
352/// of an inline ignore directive described by `ignoreDirectivePattern`.
353///
354/// The `compilationUnit` parameter is the parsed dart file containing the given
355/// [AstNode]. The `ignoreDirectivePattern` is a [Pattern] that should precisely
356/// match the ignore directive of interest (including the slashes, example:
357/// `// flutter_ignore: deprecation_syntax`).
358///
359/// The implementation assumes the `ignoreDirectivePattern` matches no more than
360/// one line. It searches for the given `ignoreDirectivePattern` in the
361/// `compilationUnit`, that either starts the line above the given `node`, or
362/// appears after `node` but on the same line, such that the ignore directive
363/// works the same way as dart's "ignore" comment: it can either be added above
364/// or after the line that needs to be exemped.
365bool hasInlineIgnore(
366 AstNode node,
367 ParseStringResult compilationUnit,
368 Pattern ignoreDirectivePattern,
369) {
370 final LineInfo lineInfo = compilationUnit.lineInfo;
371 // In case the node has multiple lines, match from its start offset.
372 final String textAfterNode = compilationUnit.content.substring(
373 node.offset,
374 // This assumes every line ends with a newline character (including the last
375 // line) and the new line character is not included to match the given pattern.
376 lineInfo.getOffsetOfLineAfter(node.offset) - 1,
377 );
378 if (textAfterNode.contains(ignoreDirectivePattern)) {
379 return true;
380 }
381 // The lineNumber getter uses one-based index while everything else uses zero-based index.
382 final int lineNumber = lineInfo.getLocation(node.offset).lineNumber - 1;
383 if (lineNumber <= 0) {
384 return false;
385 }
386 return compilationUnit.content
387 .substring(lineInfo.getOffsetOfLine(lineNumber - 1), lineInfo.getOffsetOfLine(lineNumber))
388 .trimLeft()
389 .contains(ignoreDirectivePattern);
390}
391
392// The seed used to shuffle tests. If not passed with
393// --test-randomize-ordering-seed= on the command line, it will be set the
394// first time it is accessed. Pass zero to turn off shuffling.
395String? _shuffleSeed;
396
397set shuffleSeed(String? newSeed) {
398 _shuffleSeed = newSeed;
399}
400
401String get shuffleSeed {
402 if (_shuffleSeed != null) {
403 return _shuffleSeed!;
404 }
405 // Attempt to load from the command-line argument
406 final String? seedArg = Platform.environment['--test-randomize-ordering-seed'];
407 if (seedArg != null) {
408 return seedArg;
409 }
410 // Fallback to the original time-based seed generation
411 final DateTime seedTime = DateTime.now().toUtc().subtract(const Duration(hours: 7));
412 _shuffleSeed = '${seedTime.year * 10000 + seedTime.month * 100 + seedTime.day}';
413 return _shuffleSeed!;
414}
415
416// TODO(sigmund): includeLocalEngineEnv should default to true. Currently we
417// only enable it on flutter-web test because some test suites do not work
418// properly when overriding the local engine (for example, because some platform
419// dependent targets are only built on some engines).
420// See https://github.com/flutter/flutter/issues/72368
421Future<void> runDartTest(
422 String workingDirectory, {
423 List<String>? testPaths,
424 bool enableFlutterToolAsserts = true,
425 bool useBuildRunner = false,
426 String? coverage,
427 bool forceSingleCore = false,
428 Duration? perTestTimeout,
429 bool includeLocalEngineEnv = false,
430 bool ensurePrecompiledTool = true,
431 bool shuffleTests = true,
432 bool collectMetrics = false,
433 List<String>? tags,
434 bool runSkipped = false,
435}) async {
436 // TODO(matanlurey): Consider Platform.numberOfProcessors instead.
437 // See https://github.com/flutter/flutter/issues/161399.
438 int cpus = 2;
439
440 // Integration tests that depend on external processes like chrome
441 // can get stuck if there are multiple instances running at once.
442 if (forceSingleCore) {
443 cpus = 1;
444 }
445
446 const LocalFileSystem fileSystem = LocalFileSystem();
447 final String suffix = DateTime.now().microsecondsSinceEpoch.toString();
448 final File metricFile = fileSystem.systemTempDirectory.childFile('metrics_$suffix.json');
449 final List<String> args = <String>[
450 'run',
451 'test',
452 '--reporter=expanded',
453 '--file-reporter=json:${metricFile.path}',
454 if (shuffleTests) '--test-randomize-ordering-seed=$shuffleSeed',
455 '-j$cpus',
456 if (!hasColor) '--no-color',
457 if (coverage != null) '--coverage=$coverage',
458 if (perTestTimeout != null) '--timeout=${perTestTimeout.inMilliseconds}ms',
459 if (runSkipped) '--run-skipped',
460 if (tags != null) ...tags.map((String t) => '--tags=$t'),
461 if (testPaths != null)
462 for (final String testPath in testPaths) testPath,
463 ];
464 final Map<String, String> environment = <String, String>{
465 'FLUTTER_ROOT': flutterRoot,
466 if (includeLocalEngineEnv) ...localEngineEnv,
467 if (Directory(pubCache).existsSync()) 'PUB_CACHE': pubCache,
468 };
469 if (enableFlutterToolAsserts) {
470 adjustEnvironmentToEnableFlutterAsserts(environment);
471 }
472 if (ensurePrecompiledTool) {
473 // We rerun the `flutter` tool here just to make sure that it is compiled
474 // before tests run, because the tests might time out if they have to rebuild
475 // the tool themselves.
476 await runCommand(flutter, <String>['--version'], environment: environment);
477 }
478 await runCommand(
479 dart,
480 args,
481 workingDirectory: workingDirectory,
482 environment: environment,
483 removeLine: useBuildRunner ? (String line) => line.startsWith('[INFO]') : null,
484 );
485
486 if (dryRun) {
487 return;
488 }
489
490 final TestFileReporterResults test = TestFileReporterResults.fromFile(
491 metricFile,
492 ); // --file-reporter name
493 final File info = fileSystem.file(path.join(flutterRoot, 'error.log'));
494 info.writeAsStringSync(json.encode(test.errors));
495
496 if (collectMetrics) {
497 try {
498 final List<String> testList = <String>[];
499 final Map<int, TestSpecs> allTestSpecs = test.allTestSpecs;
500 for (final TestSpecs testSpecs in allTestSpecs.values) {
501 testList.add(testSpecs.toJson());
502 }
503 if (testList.isNotEmpty) {
504 final String testJson = json.encode(testList);
505 final File testResults = fileSystem.file(path.join(flutterRoot, 'test_results.json'));
506 testResults.writeAsStringSync(testJson);
507 }
508 } on fs.FileSystemException catch (e) {
509 print('Failed to generate metrics: $e');
510 }
511 }
512
513 // metriciFile is a transitional file that needs to be deleted once it is parsed.
514 // TODO(godofredoc): Ensure metricFile is parsed and aggregated before deleting.
515 // https://github.com/flutter/flutter/issues/146003
516 metricFile.deleteSync();
517}
518
519Future<void> runFlutterTest(
520 String workingDirectory, {
521 String? script,
522 bool expectFailure = false,
523 bool printOutput = true,
524 OutputChecker? outputChecker,
525 List<String> options = const <String>[],
526 Map<String, String>? environment,
527 List<String> tests = const <String>[],
528 bool shuffleTests = true,
529 bool fatalWarnings = true,
530}) async {
531 assert(
532 !printOutput || outputChecker == null,
533 'Output either can be printed or checked but not both',
534 );
535
536 final List<String> tags = <String>[];
537 // Recipe-configured reduced test shards will only execute tests with the
538 // appropriate tag.
539 if (Platform.environment['REDUCED_TEST_SET'] == 'True') {
540 tags.addAll(<String>['-t', 'reduced-test-set']);
541 }
542
543 const LocalFileSystem fileSystem = LocalFileSystem();
544 final String suffix = DateTime.now().microsecondsSinceEpoch.toString();
545 final File metricFile = fileSystem.systemTempDirectory.childFile('metrics_$suffix.json');
546 final List<String> args = <String>[
547 'test',
548 '--reporter=expanded',
549 '--file-reporter=json:${metricFile.path}',
550 if (shuffleTests && !_isRandomizationOff) '--test-randomize-ordering-seed=$shuffleSeed',
551 if (fatalWarnings) '--fatal-warnings',
552 ...options,
553 ...tags,
554 ...flutterTestArgs,
555 ];
556
557 if (script != null) {
558 final String fullScriptPath = path.join(workingDirectory, script);
559 if (!FileSystemEntity.isFileSync(fullScriptPath)) {
560 foundError(<String>[
561 '${red}Could not find test$reset: $green$fullScriptPath$reset',
562 'Working directory: $cyan$workingDirectory$reset',
563 'Script: $green$script$reset',
564 if (!printOutput) 'This is one of the tests that does not normally print output.',
565 ]);
566 return;
567 }
568 args.add(script);
569 }
570
571 args.addAll(tests);
572
573 final OutputMode outputMode = outputChecker == null && printOutput
574 ? OutputMode.print
575 : OutputMode.capture;
576
577 final CommandResult result = await runCommand(
578 flutter,
579 args,
580 workingDirectory: workingDirectory,
581 expectNonZeroExit: expectFailure,
582 outputMode: outputMode,
583 environment: environment,
584 );
585
586 // metriciFile is a transitional file that needs to be deleted once it is parsed.
587 // TODO(godofredoc): Ensure metricFile is parsed and aggregated before deleting.
588 // https://github.com/flutter/flutter/issues/146003
589 if (!dryRun) {
590 metricFile.deleteSync();
591 }
592
593 if (outputChecker != null) {
594 final String? message = outputChecker(result);
595 if (message != null) {
596 foundError(<String>[message]);
597 }
598 }
599}
600
601/// This will force the next run of the Flutter tool (if it uses the provided
602/// environment) to have asserts enabled, by setting an environment variable.
603void adjustEnvironmentToEnableFlutterAsserts(Map<String, String> environment) {
604 // If an existing env variable exists append to it, but only if
605 // it doesn't appear to already include enable-asserts.
606 String toolsArgs = Platform.environment['FLUTTER_TOOL_ARGS'] ?? '';
607 if (!toolsArgs.contains('--enable-asserts')) {
608 toolsArgs += ' --enable-asserts';
609 }
610 environment['FLUTTER_TOOL_ARGS'] = toolsArgs.trim();
611}
612
613Future<void> selectShard(Map<String, ShardRunner> shards) =>
614 _runFromList(shards, kShardKey, 'shard', 0);
615Future<void> selectSubshard(Map<String, ShardRunner> subshards) =>
616 _runFromList(subshards, kSubshardKey, 'subshard', 1);
617
618Future<void> runShardRunnerIndexOfTotalSubshard(List<ShardRunner> tests) async {
619 final List<ShardRunner> sublist = selectIndexOfTotalSubshard<ShardRunner>(tests);
620 for (final ShardRunner test in sublist) {
621 await test();
622 }
623}
624
625/// Parse (one-)index/total-named subshards from environment variable SUBSHARD
626/// and equally distribute [tests] between them.
627/// The format of SUBSHARD is "{index}_{total number of shards}".
628/// The scheduler can change the number of total shards without needing an additional
629/// commit in this repository.
630///
631/// Examples:
632/// 1_3
633/// 2_3
634/// 3_3
635List<T> selectIndexOfTotalSubshard<T>(List<T> tests, {String subshardKey = kSubshardKey}) {
636 // Example: "1_3" means the first (one-indexed) shard of three total shards.
637 final String? subshardName = Platform.environment[subshardKey];
638 if (subshardName == null) {
639 print('$kSubshardKey environment variable is missing, skipping sharding');
640 return tests;
641 }
642 printProgress('$bold$subshardKey=$subshardName$reset');
643
644 final RegExp pattern = RegExp(r'^(\d+)_(\d+)$');
645 final Match? match = pattern.firstMatch(subshardName);
646 if (match == null || match.groupCount != 2) {
647 foundError(<String>[
648 '${red}Invalid subshard name "$subshardName". Expected format "[int]_[int]" ex. "1_3"',
649 ]);
650 throw Exception('Invalid subshard name: $subshardName');
651 }
652 // One-indexed.
653 final int index = int.parse(match.group(1)!);
654 final int total = int.parse(match.group(2)!);
655 if (index > total) {
656 foundError(<String>[
657 '${red}Invalid subshard name "$subshardName". Index number must be greater or equal to total.',
658 ]);
659 return <T>[];
660 }
661
662 final (int start, int end) = selectTestsForSubShard(
663 testCount: tests.length,
664 subShardIndex: index,
665 subShardCount: total,
666 );
667 print('Selecting subshard $index of $total (tests ${start + 1}-$end of ${tests.length})');
668 return tests.sublist(start, end);
669}
670
671/// Finds the interval of tests that a subshard is responsible for testing.
672@visibleForTesting
673(int start, int end) selectTestsForSubShard({
674 required int testCount,
675 required int subShardIndex,
676 required int subShardCount,
677}) {
678 // While there exists a closed formula figuring out the range of tests the
679 // subshard is responsible for, modeling this as a simulation of distributing
680 // items equally into buckets is more intuitive.
681 //
682 // A bucket represents how many tests a subshard should be allocated.
683 final List<int> buckets = List<int>.filled(subShardCount, 0);
684 // First, allocate an equal number of items to each bucket.
685 for (int i = 0; i < buckets.length; i++) {
686 buckets[i] = (testCount / subShardCount).floor();
687 }
688 // For the N leftover items, put one into each of the first N buckets.
689 final int remainingItems = testCount % buckets.length;
690 for (int i = 0; i < remainingItems; i++) {
691 buckets[i] += 1;
692 }
693
694 // Lastly, compute the indices of the items in buckets[index].
695 // We derive this from the toal number items in previous buckets and the number
696 // of items in this bucket.
697 final int numberOfItemsInPreviousBuckets = subShardIndex == 0
698 ? 0
699 : buckets.sublist(0, subShardIndex - 1).sum;
700 final int start = numberOfItemsInPreviousBuckets;
701 final int end = start + buckets[subShardIndex - 1];
702
703 return (start, end);
704}
705
706Future<void> _runFromList(
707 Map<String, ShardRunner> items,
708 String key,
709 String name,
710 int positionInTaskName,
711) async {
712 try {
713 final String? item = Platform.environment[key];
714 if (item == null) {
715 for (final String currentItem in items.keys) {
716 printProgress('$bold$key=$currentItem$reset');
717 await items[currentItem]!();
718 }
719 } else {
720 printProgress('$bold$key=$item$reset');
721 if (!items.containsKey(item)) {
722 foundError(<String>[
723 '${red}Invalid $name: $item$reset',
724 'The available ${name}s are: ${items.keys.join(", ")}',
725 ]);
726 return;
727 }
728 await items[item]!();
729 }
730 } catch (_) {
731 if (!dryRun) {
732 rethrow;
733 }
734 }
735}
736
737/// Checks the given file's contents to determine if they match the allowed
738/// pattern for version strings.
739///
740/// Returns null if the contents are good. Returns a string if they are bad.
741/// The string is an error message.
742Future<String?> verifyVersion(File file) async {
743 final RegExp pattern = RegExp(r'^(\d+)\.(\d+)\.(\d+)((-\d+\.\d+)?\.pre([-\.]\d+)?)?$');
744 if (!file.existsSync()) {
745 return 'The version logic failed to create the Flutter version file.';
746 }
747 final String version = await file.readAsString();
748 if (version == '0.0.0-unknown') {
749 return 'The version logic failed to determine the Flutter version.';
750 }
751 if (!version.contains(pattern)) {
752 return 'The version logic generated an invalid version string: "$version".';
753 }
754 return null;
755}
756