| 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 | |
| 5 | import 'dart:async'; |
| 6 | import 'dart:convert'; |
| 7 | import 'dart:core' hide print; |
| 8 | import 'dart:io' as system show exit; |
| 9 | import 'dart:io' hide exit; |
| 10 | import 'dart:math' as math; |
| 11 | |
| 12 | import 'package:analyzer/dart/analysis/results.dart' ; |
| 13 | import 'package:analyzer/dart/ast/ast.dart' ; |
| 14 | import 'package:analyzer/source/line_info.dart' ; |
| 15 | import 'package:collection/collection.dart' ; |
| 16 | import 'package:file/file.dart' as fs; |
| 17 | import 'package:file/local.dart' ; |
| 18 | import 'package:meta/meta.dart' ; |
| 19 | import 'package:path/path.dart' as path; |
| 20 | |
| 21 | import 'run_command.dart'; |
| 22 | import 'tool_subsharding.dart'; |
| 23 | |
| 24 | typedef 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. |
| 32 | typedef OutputChecker = String? Function(CommandResult); |
| 33 | |
| 34 | const 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. |
| 39 | final bool isLuci = Platform.environment['LUCI_CI' ] == 'True' ; |
| 40 | final bool hasColor = stdout.supportsAnsiEscapes && !isLuci; |
| 41 | final bool _isRandomizationOff = |
| 42 | bool.tryParse(Platform.environment['TEST_RANDOMIZATION_OFF' ] ?? '' ) ?? false; |
| 43 | |
| 44 | final String bold = hasColor ? '\x1B[1m' : '' ; // shard titles |
| 45 | final String red = hasColor ? '\x1B[31m' : '' ; // errors |
| 46 | final String green = hasColor ? '\x1B[32m' : '' ; // section titles, commands |
| 47 | final String yellow = hasColor |
| 48 | ? '\x1B[33m' |
| 49 | : '' ; // indications that a test was skipped (usually renders orange or brown) |
| 50 | final String cyan = hasColor ? '\x1B[36m' : '' ; // paths |
| 51 | final String reverse = hasColor ? '\x1B[7m' : '' ; // clocks |
| 52 | final String gray = hasColor |
| 53 | ? '\x1B[30m' |
| 54 | : '' ; // subtle decorative items (usually renders as dark gray) |
| 55 | final String white = hasColor ? '\x1B[37m' : '' ; // last log line (usually renders as light gray) |
| 56 | final String reset = hasColor ? '\x1B[0m' : '' ; |
| 57 | |
| 58 | final String exe = Platform.isWindows ? '.exe' : '' ; |
| 59 | final String bat = Platform.isWindows ? '.bat' : '' ; |
| 60 | final String flutterRoot = path.dirname(path.dirname(path.dirname(path.fromUri(Platform.script)))); |
| 61 | final String flutter = path.join(flutterRoot, 'bin' , 'flutter $bat' ); |
| 62 | final String dart = path.join(flutterRoot, 'bin' , 'cache' , 'dart-sdk' , 'bin' , 'dart $exe' ); |
| 63 | final String pubCache = path.join(flutterRoot, '.pub-cache' ); |
| 64 | final String engineVersionFile = path.join(flutterRoot, 'bin' , 'cache' , 'engine.stamp' ); |
| 65 | final String luciBotId = Platform.environment['SWARMING_BOT_ID' ] ?? '' ; |
| 66 | final bool runningInDartHHHBot = |
| 67 | luciBotId.startsWith('luci-dart-' ) || luciBotId.startsWith('dart-tests-' ); |
| 68 | |
| 69 | const String kShardKey = 'SHARD' ; |
| 70 | const String kSubshardKey = 'SUBSHARD' ; |
| 71 | const 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`. |
| 75 | final 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. |
| 79 | final 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. |
| 87 | bool get dryRun => _dryRun ?? false; |
| 88 | |
| 89 | /// Switches [dryRun] to `true`. |
| 90 | /// |
| 91 | /// Expected to be called at most once during execution of a process. |
| 92 | void enableDryRun() { |
| 93 | if (_dryRun != null) { |
| 94 | throw StateError('Should only be called at most once' ); |
| 95 | } |
| 96 | _dryRun = true; |
| 97 | } |
| 98 | |
| 99 | bool? _dryRun; |
| 100 | |
| 101 | const int kESC = 0x1B; |
| 102 | const int kOpenSquareBracket = 0x5B; |
| 103 | const int kCSIParameterRangeStart = 0x30; |
| 104 | const int kCSIParameterRangeEnd = 0x3F; |
| 105 | const int kCSIIntermediateRangeStart = 0x20; |
| 106 | const int kCSIIntermediateRangeEnd = 0x2F; |
| 107 | const int kCSIFinalRangeStart = 0x40; |
| 108 | const int kCSIFinalRangeEnd = 0x7E; |
| 109 | |
| 110 | int get terminalColumns { |
| 111 | try { |
| 112 | return stdout.terminalColumns; |
| 113 | } catch (e) { |
| 114 | return 40; |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | String get redLine { |
| 119 | if (hasColor) { |
| 120 | return ' $red${'━' * terminalColumns}$reset' ; |
| 121 | }
|
| 122 | return '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' ;
|
| 123 | }
|
| 124 |
|
| 125 | String 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 |
|
| 134 | String 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 |
|
| 146 | typedef PrintCallback = void Function(Object? line);
|
| 147 | typedef 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.
|
| 158 | PrintCallback print = _printQuietly;
|
| 159 |
|
| 160 | // Called by foundError and used to implement `--abort-on-error` in test.dart.
|
| 161 | VoidCallback? onError;
|
| 162 |
|
| 163 | bool get hasError => _hasError;
|
| 164 | bool _hasError = false;
|
| 165 |
|
| 166 | List<List<String>> _errorMessages = <List<String>>[];
|
| 167 |
|
| 168 | final List<String> _pendingLogs = <String>[];
|
| 169 | Timer? _hideTimer; // When this is null, the output is verbose.
|
| 170 |
|
| 171 | void 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
|
| 198 | void resetErrorStatus() {
|
| 199 | _hasError = false;
|
| 200 | _errorMessages.clear();
|
| 201 | _pendingLogs.clear();
|
| 202 | _hideTimer?.cancel();
|
| 203 | _hideTimer = null;
|
| 204 | }
|
| 205 |
|
| 206 | Never reportSuccessAndExit(String message) {
|
| 207 | _hideTimer?.cancel();
|
| 208 | _hideTimer = null;
|
| 209 | print(' $clock $message$reset' );
|
| 210 | system.exit(0);
|
| 211 | }
|
| 212 |
|
| 213 | Never 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 |
|
| 235 | void 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 |
|
| 254 | final Pattern _lineBreak = RegExp(r'[\r\n]' );
|
| 255 |
|
| 256 | void _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 |
|
| 310 | void _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 |
|
| 326 | int _portCounter = 8080;
|
| 327 |
|
| 328 | /// Finds the next available local port.
|
| 329 | Future<int> findAvailablePortAndPossiblyCauseFlakyTests() async {
|
| 330 | while (!await _isPortAvailable(_portCounter)) {
|
| 331 | _portCounter += 1;
|
| 332 | }
|
| 333 | return _portCounter++;
|
| 334 | }
|
| 335 |
|
| 336 | Future<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 |
|
| 347 | String 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.
|
| 365 | bool 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.
|
| 395 | String? _shuffleSeed;
|
| 396 |
|
| 397 | set shuffleSeed(String? newSeed) {
|
| 398 | _shuffleSeed = newSeed;
|
| 399 | }
|
| 400 |
|
| 401 | String 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
|
| 421 | Future<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 |
|
| 519 | Future<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.
|
| 603 | void 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 |
|
| 613 | Future<void> selectShard(Map<String, ShardRunner> shards) =>
|
| 614 | _runFromList(shards, kShardKey, 'shard' , 0);
|
| 615 | Future<void> selectSubshard(Map<String, ShardRunner> subshards) =>
|
| 616 | _runFromList(subshards, kSubshardKey, 'subshard' , 1);
|
| 617 |
|
| 618 | Future<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
|
| 635 | List<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 |
|
| 706 | Future<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.
|
| 742 | Future<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 |
|