flutter/dev/bots/utils.dart
Jesse 9689f7f89c
Refactor framework + test harness tests (#146213)
Refactor the framework + test harness test suites in order to reduce testing logic in test.dart and allow for later implementing package:test onto the existing tests

The refactor of both suites included in this PR because they both depended on util functions and variables that also needed to be refactored.

Part of https://github.com/flutter/flutter/issues/145482
2024-04-23 19:29:04 +00:00

625 lines
23 KiB
Dart

// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'dart:core' hide print;
import 'dart:io' as system show exit;
import 'dart:io' hide exit;
import 'dart:math' as math;
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:file/file.dart' as fs;
import 'package:file/local.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;
import 'run_command.dart';
import 'tool_subsharding.dart';
typedef ShardRunner = Future<void> Function();
/// A function used to validate the output of a test.
///
/// If the output matches expectations, the function shall return null.
///
/// If the output does not match expectations, the function shall return an
/// appropriate error message.
typedef OutputChecker = String? Function(CommandResult);
const Duration _quietTimeout = Duration(minutes: 10); // how long the output should be hidden between calls to printProgress before just being verbose
// If running from LUCI set to False.
final bool isLuci = Platform.environment['LUCI_CI'] == 'True';
final bool hasColor = stdout.supportsAnsiEscapes && !isLuci;
final bool _isRandomizationOff = bool.tryParse(Platform.environment['TEST_RANDOMIZATION_OFF'] ?? '') ?? false;
final String bold = hasColor ? '\x1B[1m' : ''; // shard titles
final String red = hasColor ? '\x1B[31m' : ''; // errors
final String green = hasColor ? '\x1B[32m' : ''; // section titles, commands
final String yellow = hasColor ? '\x1B[33m' : ''; // indications that a test was skipped (usually renders orange or brown)
final String cyan = hasColor ? '\x1B[36m' : ''; // paths
final String reverse = hasColor ? '\x1B[7m' : ''; // clocks
final String gray = hasColor ? '\x1B[30m' : ''; // subtle decorative items (usually renders as dark gray)
final String white = hasColor ? '\x1B[37m' : ''; // last log line (usually renders as light gray)
final String reset = hasColor ? '\x1B[0m' : '';
final String exe = Platform.isWindows ? '.exe' : '';
final String bat = Platform.isWindows ? '.bat' : '';
final String flutterRoot = path.dirname(path.dirname(path.dirname(path.fromUri(Platform.script))));
final String flutter = path.join(flutterRoot, 'bin', 'flutter$bat');
final String dart = path.join(flutterRoot, 'bin', 'cache', 'dart-sdk', 'bin', 'dart$exe');
final String pubCache = path.join(flutterRoot, '.pub-cache');
final String engineVersionFile = path.join(flutterRoot, 'bin', 'internal', 'engine.version');
final String luciBotId = Platform.environment['SWARMING_BOT_ID'] ?? '';
final bool runningInDartHHHBot =
luciBotId.startsWith('luci-dart-') || luciBotId.startsWith('dart-tests-');
const String kShardKey = 'SHARD';
const String kSubshardKey = 'SUBSHARD';
const String kTestHarnessShardName = 'test_harness_tests';
const String CIRRUS_TASK_NAME = 'CIRRUS_TASK_NAME';
/// Environment variables to override the local engine when running `pub test`,
/// if such flags are provided to `test.dart`.
final Map<String,String> localEngineEnv = <String, String>{};
/// The arguments to pass to `flutter test` (typically the local engine
/// configuration) -- prefilled with the arguments passed to test.dart.
final List<String> flutterTestArgs = <String>[];
const int kESC = 0x1B;
const int kOpenSquareBracket = 0x5B;
const int kCSIParameterRangeStart = 0x30;
const int kCSIParameterRangeEnd = 0x3F;
const int kCSIIntermediateRangeStart = 0x20;
const int kCSIIntermediateRangeEnd = 0x2F;
const int kCSIFinalRangeStart = 0x40;
const int kCSIFinalRangeEnd = 0x7E;
String get redLine {
if (hasColor) {
return '$red${'' * stdout.terminalColumns}$reset';
}
return '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
}
String get clock {
final DateTime now = DateTime.now();
return '$reverse'
'${now.hour.toString().padLeft(2, "0")}:'
'${now.minute.toString().padLeft(2, "0")}:'
'${now.second.toString().padLeft(2, "0")}'
'$reset';
}
String prettyPrintDuration(Duration duration) {
String result = '';
final int minutes = duration.inMinutes;
if (minutes > 0) {
result += '${minutes}min ';
}
final int seconds = duration.inSeconds - minutes * 60;
final int milliseconds = duration.inMilliseconds - (seconds * 1000 + minutes * 60 * 1000);
result += '$seconds.${milliseconds.toString().padLeft(3, "0")}s';
return result;
}
typedef PrintCallback = void Function(Object? line);
typedef VoidCallback = void Function();
// Allow print() to be overridden, for tests.
//
// Files that import this library should not import `print` from dart:core
// and should not use dart:io's `stdout` or `stderr`.
//
// By default this hides log lines between `printProgress` calls unless a
// timeout expires or anything calls `foundError`.
//
// Also used to implement `--verbose` in test.dart.
PrintCallback print = _printQuietly;
// Called by foundError and used to implement `--abort-on-error` in test.dart.
VoidCallback? onError;
bool get hasError => _hasError;
bool _hasError = false;
List<List<String>> _errorMessages = <List<String>>[];
final List<String> _pendingLogs = <String>[];
Timer? _hideTimer; // When this is null, the output is verbose.
void foundError(List<String> messages) {
assert(messages.isNotEmpty);
// Make the error message easy to notice in the logs by
// wrapping it in a red box.
final int width = math.max(15, (hasColor ? stdout.terminalColumns : 80) - 1);
final String title = 'ERROR #${_errorMessages.length + 1}';
print('$red╔═╡$bold$title$reset$red╞═${"" * (width - 4 - title.length)}');
for (final String message in messages.expand((String line) => line.split('\n'))) {
print('$red$reset $message');
}
print('$red${"" * width}');
// Normally, "print" actually prints to the log. To make the errors visible,
// and to include useful context, print the entire log up to this point, and
// clear it. Subsequent messages will continue to not be logged until there is
// another error.
_pendingLogs.forEach(_printLoudly);
_pendingLogs.clear();
_errorMessages.add(messages);
_hasError = true;
onError?.call();
}
@visibleForTesting
void resetErrorStatus() {
_hasError = false;
_errorMessages.clear();
_pendingLogs.clear();
_hideTimer?.cancel();
_hideTimer = null;
}
Never reportSuccessAndExit(String message) {
_hideTimer?.cancel();
_hideTimer = null;
print('$clock $message$reset');
system.exit(0);
}
Never reportErrorsAndExit(String message) {
_hideTimer?.cancel();
_hideTimer = null;
print('$clock $message$reset');
print(redLine);
print('${red}For your convenience, the error messages reported above are repeated here:$reset');
final bool printSeparators = _errorMessages.any((List<String> messages) => messages.length > 1);
if (printSeparators) {
print(' 🙙 🙛 ');
}
for (int index = 0; index < _errorMessages.length * 2 - 1; index += 1) {
if (index.isEven) {
_errorMessages[index ~/ 2].forEach(print);
} else if (printSeparators) {
print(' 🙙 🙛 ');
}
}
print(redLine);
print('You may find the errors by searching for "╡ERROR #" in the logs.');
system.exit(1);
}
void printProgress(String message) {
_pendingLogs.clear();
_hideTimer?.cancel();
_hideTimer = null;
print('$clock $message$reset');
if (hasColor) {
// This sets up a timer to switch to verbose mode when the tests take too long,
// so that if a test hangs we can see the logs.
// (This is only supported with a color terminal. When the terminal doesn't
// support colors, the scripts just print everything verbosely, that way in
// CI there's nothing hidden.)
_hideTimer = Timer(_quietTimeout, () {
_hideTimer = null;
_pendingLogs.forEach(_printLoudly);
_pendingLogs.clear();
});
}
}
final Pattern _lineBreak = RegExp(r'[\r\n]');
void _printQuietly(Object? message) {
// The point of this function is to avoid printing its output unless the timer
// has gone off in which case the function assumes verbose mode is active and
// prints everything. To show that progress is still happening though, rather
// than showing nothing at all, it instead shows the last line of output and
// keeps overwriting it. To do this in color mode, carefully measures the line
// of text ignoring color codes, which is what the parser below does.
if (_hideTimer != null) {
_pendingLogs.add(message.toString());
String line = '$message'.trimRight();
final int start = line.lastIndexOf(_lineBreak) + 1;
int index = start;
int length = 0;
while (index < line.length && length < stdout.terminalColumns) {
if (line.codeUnitAt(index) == kESC) { // 0x1B
index += 1;
if (index < line.length && line.codeUnitAt(index) == kOpenSquareBracket) { // 0x5B, [
// That was the start of a CSI sequence.
index += 1;
while (index < line.length && line.codeUnitAt(index) >= kCSIParameterRangeStart
&& line.codeUnitAt(index) <= kCSIParameterRangeEnd) { // 0x30..0x3F
index += 1; // ...parameter bytes...
}
while (index < line.length && line.codeUnitAt(index) >= kCSIIntermediateRangeStart
&& line.codeUnitAt(index) <= kCSIIntermediateRangeEnd) { // 0x20..0x2F
index += 1; // ...intermediate bytes...
}
if (index < line.length && line.codeUnitAt(index) >= kCSIFinalRangeStart
&& line.codeUnitAt(index) <= kCSIFinalRangeEnd) { // 0x40..0x7E
index += 1; // ...final byte.
}
}
} else {
index += 1;
length += 1;
}
}
line = line.substring(start, index);
if (line.isNotEmpty) {
stdout.write('\r\x1B[2K$white$line$reset');
}
} else {
_printLoudly('$message');
}
}
void _printLoudly(String message) {
if (hasColor) {
// Overwrite the last line written by _printQuietly.
stdout.writeln('\r\x1B[2K$reset${message.trimRight()}');
} else {
stdout.writeln(message);
}
}
// THE FOLLOWING CODE IS A VIOLATION OF OUR STYLE GUIDE
// BECAUSE IT INTRODUCES A VERY FLAKY RACE CONDITION
// https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#never-check-if-a-port-is-available-before-using-it-never-add-timeouts-and-other-race-conditions
// DO NOT USE THE FOLLOWING FUNCTIONS
// DO NOT WRITE CODE LIKE THE FOLLOWING FUNCTIONS
// https://github.com/flutter/flutter/issues/109474
int _portCounter = 8080;
/// Finds the next available local port.
Future<int> findAvailablePortAndPossiblyCauseFlakyTests() async {
while (!await _isPortAvailable(_portCounter)) {
_portCounter += 1;
}
return _portCounter++;
}
Future<bool> _isPortAvailable(int port) async {
try {
final RawSocket socket = await RawSocket.connect('localhost', port);
socket.shutdown(SocketDirection.both);
await socket.close();
return false;
} on SocketException {
return true;
}
}
String locationInFile(ResolvedUnitResult unit, AstNode node, String workingDirectory) {
return '${path.relative(path.relative(unit.path, from: workingDirectory))}:${unit.lineInfo.getLocation(node.offset).lineNumber}';
}
// The seed used to shuffle tests. If not passed with
// --test-randomize-ordering-seed=<seed> on the command line, it will be set the
// first time it is accessed. Pass zero to turn off shuffling.
String? _shuffleSeed;
set shuffleSeed(String? newSeed) {
_shuffleSeed = newSeed;
}
String get shuffleSeed {
if (_shuffleSeed != null) {
return _shuffleSeed!;
}
// Attempt to load from the command-line argument
final String? seedArg = Platform.environment['--test-randomize-ordering-seed'];
if (seedArg != null) {
return seedArg;
}
// Fallback to the original time-based seed generation
final DateTime seedTime = DateTime.now().toUtc().subtract(const Duration(hours: 7));
_shuffleSeed = '${seedTime.year * 10000 + seedTime.month * 100 + seedTime.day}';
return _shuffleSeed!;
}
// TODO(sigmund): includeLocalEngineEnv should default to true. Currently we
// only enable it on flutter-web test because some test suites do not work
// properly when overriding the local engine (for example, because some platform
// dependent targets are only built on some engines).
// See https://github.com/flutter/flutter/issues/72368
Future<void> runDartTest(String workingDirectory, {
List<String>? testPaths,
bool enableFlutterToolAsserts = true,
bool useBuildRunner = false,
String? coverage,
bool forceSingleCore = false,
Duration? perTestTimeout,
bool includeLocalEngineEnv = false,
bool ensurePrecompiledTool = true,
bool shuffleTests = true,
bool collectMetrics = false,
}) async {
int? cpus;
final String? cpuVariable = Platform.environment['CPU']; // CPU is set in cirrus.yml
if (cpuVariable != null) {
cpus = int.tryParse(cpuVariable, radix: 10);
if (cpus == null) {
foundError(<String>[
'${red}The CPU environment variable, if set, must be set to the integer number of available cores.$reset',
'Actual value: "$cpuVariable"',
]);
return;
}
} else {
cpus = 2; // Don't default to 1, otherwise we won't catch race conditions.
}
// Integration tests that depend on external processes like chrome
// can get stuck if there are multiple instances running at once.
if (forceSingleCore) {
cpus = 1;
}
const LocalFileSystem fileSystem = LocalFileSystem();
final String suffix = DateTime.now().microsecondsSinceEpoch.toString();
final File metricFile = fileSystem.systemTempDirectory.childFile('metrics_$suffix.json');
final List<String> args = <String>[
'run',
'test',
'--reporter=expanded',
'--file-reporter=json:${metricFile.path}',
if (shuffleTests) '--test-randomize-ordering-seed=$shuffleSeed',
'-j$cpus',
if (!hasColor)
'--no-color',
if (coverage != null)
'--coverage=$coverage',
if (perTestTimeout != null)
'--timeout=${perTestTimeout.inMilliseconds}ms',
if (testPaths != null)
for (final String testPath in testPaths)
testPath,
];
final Map<String, String> environment = <String, String>{
'FLUTTER_ROOT': flutterRoot,
if (includeLocalEngineEnv)
...localEngineEnv,
if (Directory(pubCache).existsSync())
'PUB_CACHE': pubCache,
};
if (enableFlutterToolAsserts) {
adjustEnvironmentToEnableFlutterAsserts(environment);
}
if (ensurePrecompiledTool) {
// We rerun the `flutter` tool here just to make sure that it is compiled
// before tests run, because the tests might time out if they have to rebuild
// the tool themselves.
await runCommand(flutter, <String>['--version'], environment: environment);
}
await runCommand(
dart,
args,
workingDirectory: workingDirectory,
environment: environment,
removeLine: useBuildRunner ? (String line) => line.startsWith('[INFO]') : null,
);
final TestFileReporterResults test = TestFileReporterResults.fromFile(metricFile); // --file-reporter name
final File info = fileSystem.file(path.join(flutterRoot, 'error.log'));
info.writeAsStringSync(json.encode(test.errors));
if (collectMetrics) {
try {
final List<String> testList = <String>[];
final Map<int, TestSpecs> allTestSpecs = test.allTestSpecs;
for (final TestSpecs testSpecs in allTestSpecs.values) {
testList.add(testSpecs.toJson());
}
if (testList.isNotEmpty) {
final String testJson = json.encode(testList);
final File testResults = fileSystem.file(
path.join(flutterRoot, 'test_results.json'));
testResults.writeAsStringSync(testJson);
}
} on fs.FileSystemException catch (e) {
print('Failed to generate metrics: $e');
}
}
// metriciFile is a transitional file that needs to be deleted once it is parsed.
// TODO(godofredoc): Ensure metricFile is parsed and aggregated before deleting.
// https://github.com/flutter/flutter/issues/146003
metricFile.deleteSync();
}
Future<void> runFlutterTest(String workingDirectory, {
String? script,
bool expectFailure = false,
bool printOutput = true,
OutputChecker? outputChecker,
List<String> options = const <String>[],
Map<String, String>? environment,
List<String> tests = const <String>[],
bool shuffleTests = true,
bool fatalWarnings = true,
}) async {
assert(!printOutput || outputChecker == null, 'Output either can be printed or checked but not both');
final List<String> tags = <String>[];
// Recipe-configured reduced test shards will only execute tests with the
// appropriate tag.
if (Platform.environment['REDUCED_TEST_SET'] == 'True') {
tags.addAll(<String>['-t', 'reduced-test-set']);
}
const LocalFileSystem fileSystem = LocalFileSystem();
final String suffix = DateTime.now().microsecondsSinceEpoch.toString();
final File metricFile = fileSystem.systemTempDirectory.childFile('metrics_$suffix.json');
final List<String> args = <String>[
'test',
'--reporter=expanded',
'--file-reporter=json:${metricFile.path}',
if (shuffleTests && !_isRandomizationOff) '--test-randomize-ordering-seed=$shuffleSeed',
if (fatalWarnings) '--fatal-warnings',
...options,
...tags,
...flutterTestArgs,
];
if (script != null) {
final String fullScriptPath = path.join(workingDirectory, script);
if (!FileSystemEntity.isFileSync(fullScriptPath)) {
foundError(<String>[
'${red}Could not find test$reset: $green$fullScriptPath$reset',
'Working directory: $cyan$workingDirectory$reset',
'Script: $green$script$reset',
if (!printOutput)
'This is one of the tests that does not normally print output.',
]);
return;
}
args.add(script);
}
args.addAll(tests);
final OutputMode outputMode = outputChecker == null && printOutput
? OutputMode.print
: OutputMode.capture;
final CommandResult result = await runCommand(
flutter,
args,
workingDirectory: workingDirectory,
expectNonZeroExit: expectFailure,
outputMode: outputMode,
environment: environment,
);
// metriciFile is a transitional file that needs to be deleted once it is parsed.
// TODO(godofredoc): Ensure metricFile is parsed and aggregated before deleting.
// https://github.com/flutter/flutter/issues/146003
metricFile.deleteSync();
if (outputChecker != null) {
final String? message = outputChecker(result);
if (message != null) {
foundError(<String>[message]);
}
}
}
/// This will force the next run of the Flutter tool (if it uses the provided
/// environment) to have asserts enabled, by setting an environment variable.
void adjustEnvironmentToEnableFlutterAsserts(Map<String, String> environment) {
// If an existing env variable exists append to it, but only if
// it doesn't appear to already include enable-asserts.
String toolsArgs = Platform.environment['FLUTTER_TOOL_ARGS'] ?? '';
if (!toolsArgs.contains('--enable-asserts')) {
toolsArgs += ' --enable-asserts';
}
environment['FLUTTER_TOOL_ARGS'] = toolsArgs.trim();
}
Future<void> selectShard(Map<String, ShardRunner> shards) => _runFromList(shards, kShardKey, 'shard', 0);
Future<void> selectSubshard(Map<String, ShardRunner> subshards) => _runFromList(subshards, kSubshardKey, 'subshard', 1);
Future<void> runShardRunnerIndexOfTotalSubshard(List<ShardRunner> tests) async {
final List<ShardRunner> sublist = selectIndexOfTotalSubshard<ShardRunner>(tests);
for (final ShardRunner test in sublist) {
await test();
}
}
/// Parse (one-)index/total-named subshards from environment variable SUBSHARD
/// and equally distribute [tests] between them.
/// Subshard format is "{index}_{total number of shards}".
/// The scheduler can change the number of total shards without needing an additional
/// commit in this repository.
///
/// Examples:
/// 1_3
/// 2_3
/// 3_3
List<T> selectIndexOfTotalSubshard<T>(List<T> tests, {String subshardKey = kSubshardKey}) {
// Example: "1_3" means the first (one-indexed) shard of three total shards.
final String? subshardName = Platform.environment[subshardKey];
if (subshardName == null) {
print('$kSubshardKey environment variable is missing, skipping sharding');
return tests;
}
printProgress('$bold$subshardKey=$subshardName$reset');
final RegExp pattern = RegExp(r'^(\d+)_(\d+)$');
final Match? match = pattern.firstMatch(subshardName);
if (match == null || match.groupCount != 2) {
foundError(<String>[
'${red}Invalid subshard name "$subshardName". Expected format "[int]_[int]" ex. "1_3"',
]);
throw Exception('Invalid subshard name: $subshardName');
}
// One-indexed.
final int index = int.parse(match.group(1)!);
final int total = int.parse(match.group(2)!);
if (index > total) {
foundError(<String>[
'${red}Invalid subshard name "$subshardName". Index number must be greater or equal to total.',
]);
return <T>[];
}
final int testsPerShard = (tests.length / total).ceil();
final int start = (index - 1) * testsPerShard;
final int end = math.min(index * testsPerShard, tests.length);
print('Selecting subshard $index of $total (tests ${start + 1}-$end of ${tests.length})');
return tests.sublist(start, end);
}
Future<void> _runFromList(Map<String, ShardRunner> items, String key, String name, int positionInTaskName) async {
String? item = Platform.environment[key];
if (item == null && Platform.environment.containsKey(CIRRUS_TASK_NAME)) {
final List<String> parts = Platform.environment[CIRRUS_TASK_NAME]!.split('-');
assert(positionInTaskName < parts.length);
item = parts[positionInTaskName];
}
if (item == null) {
for (final String currentItem in items.keys) {
printProgress('$bold$key=$currentItem$reset');
await items[currentItem]!();
}
} else {
printProgress('$bold$key=$item$reset');
if (!items.containsKey(item)) {
foundError(<String>[
'${red}Invalid $name: $item$reset',
'The available ${name}s are: ${items.keys.join(", ")}',
]);
return;
}
await items[item]!();
}
}
/// Checks the given file's contents to determine if they match the allowed
/// pattern for version strings.
///
/// Returns null if the contents are good. Returns a string if they are bad.
/// The string is an error message.
Future<String?> verifyVersion(File file) async {
final RegExp pattern = RegExp(
r'^(\d+)\.(\d+)\.(\d+)((-\d+\.\d+)?\.pre(\.\d+)?)?$');
if (!file.existsSync()) {
return 'The version logic failed to create the Flutter version file.';
}
final String version = await file.readAsString();
if (version == '0.0.0-unknown') {
return 'The version logic failed to determine the Flutter version.';
}
if (!version.contains(pattern)) {
return 'The version logic generated an invalid version string: "$version".';
}
return null;
}