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:analyzer/source/line_info.dart';
import 'package:collection/collection.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 'luci_resultdb.dart';
import 'run_command.dart';
import 'tool_subsharding.dart';
typedef ShardRunner = Future<void> Function();
typedef OutputChecker = String? Function(CommandResult);
const Duration _quietTimeout = Duration(
minutes: 10,
);
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' : '';
final String red = hasColor ? '\x1B[31m' : '';
final String green = hasColor ? '\x1B[32m' : '';
final String yellow = hasColor
? '\x1B[33m'
: '';
final String cyan = hasColor ? '\x1B[36m' : '';
final String reverse = hasColor ? '\x1B[7m' : '';
final String gray = hasColor
? '\x1B[30m'
: '';
final String white = hasColor ? '\x1B[37m' : '';
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', 'cache', 'engine.stamp');
final String engineInfoFile = path.join(flutterRoot, 'bin', 'cache', 'engine_stamp.json');
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';
final Map<String, String> localEngineEnv = <String, String>{};
final List<String> flutterTestArgs = <String>[];
bool get dryRun => _dryRun ?? false;
void enableDryRun() {
if (_dryRun != null) {
throw StateError('Should only be called at most once');
}
_dryRun = true;
}
bool? _dryRun;
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;
int get terminalColumns {
try {
return stdout.terminalColumns;
} catch (e) {
return 40;
}
}
String get redLine {
if (hasColor) {
return '$red${'━' * terminalColumns}$reset';
}
return '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
}
String get clock {
final 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) {
var 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();
PrintCallback print = _printQuietly;
VoidCallback? onError;
bool get hasError => _hasError;
bool _hasError = false;
List<List<String>> _errorMessages = <List<String>>[];
final List<String> _pendingLogs = <String>[];
Timer? _hideTimer;
void foundError(List<String> messages) {
if (dryRun) {
printProgress(messages.join('\n'));
return;
}
assert(messages.isNotEmpty);
final int width = math.max(15, (hasColor ? terminalColumns : 80) - 1);
final 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}');
_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}The error messages reported above are repeated here:$reset');
final bool printSeparators = _errorMessages.any((List<String> messages) => messages.length > 1);
if (printSeparators) {
print(' -- This line intentionally left blank -- ');
}
for (var index = 0; index < _errorMessages.length * 2 - 1; index += 1) {
if (index.isEven) {
_errorMessages[index ~/ 2].forEach(print);
} else if (printSeparators) {
print(' -- This line intentionally left blank -- ');
}
}
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) {
_hideTimer = Timer(_quietTimeout, () {
_hideTimer = null;
_pendingLogs.forEach(_printLoudly);
_pendingLogs.clear();
});
}
}
final Pattern _lineBreak = RegExp(r'[\r\n]');
void _printQuietly(Object? message) {
if (_hideTimer != null) {
_pendingLogs.add(message.toString());
String line = '$message'.trimRight();
final int start = line.lastIndexOf(_lineBreak) + 1;
var index = start;
var length = 0;
while (index < line.length && length < terminalColumns) {
if (line.codeUnitAt(index) == kESC) {
index += 1;
if (index < line.length && line.codeUnitAt(index) == kOpenSquareBracket) {
index += 1;
while (index < line.length &&
line.codeUnitAt(index) >= kCSIParameterRangeStart &&
line.codeUnitAt(index) <= kCSIParameterRangeEnd) {
index += 1;
}
while (index < line.length &&
line.codeUnitAt(index) >= kCSIIntermediateRangeStart &&
line.codeUnitAt(index) <= kCSIIntermediateRangeEnd) {
index += 1;
}
if (index < line.length &&
line.codeUnitAt(index) >= kCSIFinalRangeStart &&
line.codeUnitAt(index) <= kCSIFinalRangeEnd) {
index += 1;
}
}
} 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) {
stdout.writeln('\r\x1B[2K$reset${message.trimRight()}');
} else {
stdout.writeln(message);
}
}
int _portCounter = 8080;
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}';
}
bool hasInlineIgnore(
AstNode node,
ParseStringResult compilationUnit,
Pattern ignoreDirectivePattern,
) {
final LineInfo lineInfo = compilationUnit.lineInfo;
final String textAfterNode = compilationUnit.content.substring(
node.offset,
lineInfo.getOffsetOfLineAfter(node.offset) - 1,
);
if (textAfterNode.contains(ignoreDirectivePattern)) {
return true;
}
final int lineNumber = lineInfo.getLocation(node.offset).lineNumber - 1;
if (lineNumber <= 0) {
return false;
}
return compilationUnit.content
.substring(lineInfo.getOffsetOfLine(lineNumber - 1), lineInfo.getOffsetOfLine(lineNumber))
.trimLeft()
.contains(ignoreDirectivePattern);
}
String? _shuffleSeed;
set shuffleSeed(String? newSeed) {
_shuffleSeed = newSeed;
}
String get shuffleSeed {
if (_shuffleSeed != null) {
return _shuffleSeed!;
}
final String? seedArg = Platform.environment['--test-randomize-ordering-seed'];
if (seedArg != null) {
return seedArg;
}
final DateTime seedTime = DateTime.now().toUtc().subtract(const Duration(hours: 7));
_shuffleSeed = '${seedTime.year * 10000 + seedTime.month * 100 + seedTime.day}';
return _shuffleSeed!;
}
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,
List<String>? tags,
bool runSkipped = false,
}) async {
var cpus = 2;
if (forceSingleCore) {
cpus = 1;
}
const fileSystem = LocalFileSystem();
final suffix = DateTime.now().microsecondsSinceEpoch.toString();
final File metricFile = fileSystem.systemTempDirectory.childFile('metrics_$suffix.json');
final 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 (runSkipped) '--run-skipped',
...?tags?.map((String t) => '--tags=$t'),
if (testPaths != null)
for (final String testPath in testPaths) testPath,
];
final environment = <String, String>{
'FLUTTER_ROOT': flutterRoot,
if (includeLocalEngineEnv) ...localEngineEnv,
if (Directory(pubCache).existsSync()) 'PUB_CACHE': pubCache,
};
if (enableFlutterToolAsserts) {
adjustEnvironmentToEnableFlutterAsserts(environment);
}
if (ensurePrecompiledTool) {
await runCommand(flutter, <String>['--version'], environment: environment);
}
await runCommand(
dart,
args,
workingDirectory: workingDirectory,
environment: environment,
removeLine: useBuildRunner ? (String line) => line.startsWith('[INFO]') : null,
);
if (dryRun) {
return;
}
final test = TestFileReporterResults.fromFile(metricFile);
final File info = fileSystem.file(path.join(flutterRoot, 'error.log'));
info.writeAsStringSync(json.encode(test.errors));
if (collectMetrics) {
try {
final 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');
}
}
await reportTestResultsToResultDb(test, workingDirectory: workingDirectory);
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 tags = <String>[];
if (Platform.environment['REDUCED_TEST_SET'] == 'True') {
tags.addAll(<String>['-t', 'reduced-test-set']);
}
const fileSystem = LocalFileSystem();
final suffix = DateTime.now().microsecondsSinceEpoch.toString();
final File metricFile = fileSystem.systemTempDirectory.childFile('metrics_$suffix.json');
final 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,
);
if (!dryRun) {
if (metricFile.existsSync()) {
final test = TestFileReporterResults.fromFile(metricFile);
await reportTestResultsToResultDb(
test,
workingDirectory: workingDirectory,
expectFailure: expectFailure,
);
}
metricFile.deleteSync();
}
if (outputChecker != null) {
final String? message = outputChecker(result);
if (message != null) {
foundError(<String>[message]);
}
}
}
Future<void> reportTestResultsToResultDb(
TestFileReporterResults test, {
String? workingDirectory,
bool expectFailure = false,
}) async {
final List<LuciTestResult> results = convertToLuciTestResultsFormat(
test,
expectFailure: expectFailure,
workingDirectory: workingDirectory,
rootDirectory: flutterRoot,
);
if (results.isEmpty) {
return;
}
ResultDbRecorder? recorder;
try {
recorder = ResultDbRecorder.fromEnvironment();
if (recorder == null) {
print('ResultDB is not available; skipping test result reporting.');
return;
}
await recorder.reportTestResults(results);
print('Reported ${results.length} test result(s) to ResultDB.');
} catch (e) {
print('Failed to report test results to ResultDB: $e');
} finally {
recorder?.close();
}
}
void adjustEnvironmentToEnableFlutterAsserts(Map<String, String> environment) {
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 test in sublist) {
await test();
}
}
List<T> selectIndexOfTotalSubshard<T>(List<T> tests, {String subshardKey = kSubshardKey}) {
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 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');
}
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 start, int end) = selectTestsForSubShard(
testCount: tests.length,
subShardIndex: index,
subShardCount: total,
);
print('Selecting subshard $index of $total (tests ${start + 1}-$end of ${tests.length})');
return tests.sublist(start, end);
}
@visibleForTesting
(int start, int end) selectTestsForSubShard({
required int testCount,
required int subShardIndex,
required int subShardCount,
}) {
final buckets = List<int>.filled(subShardCount, 0);
for (var i = 0; i < buckets.length; i++) {
buckets[i] = (testCount / subShardCount).floor();
}
final int remainingItems = testCount % buckets.length;
for (var i = 0; i < remainingItems; i++) {
buckets[i] += 1;
}
final int numberOfItemsInPreviousBuckets = subShardIndex == 0
? 0
: buckets.sublist(0, subShardIndex - 1).sum;
final start = numberOfItemsInPreviousBuckets;
final int end = start + buckets[subShardIndex - 1];
return (start, end);
}
Future<void> _runFromList(
Map<String, ShardRunner> items,
String key,
String name,
int positionInTaskName,
) async {
try {
final String? item = Platform.environment[key];
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]!();
}
} catch (_) {
if (!dryRun) {
rethrow;
}
}
}
sealed class Version {
static final RegExp _pattern = RegExp(r'^(\d+)\.(\d+)\.(\d+)((-\d+\.\d+)?\.pre([-\.]\d+)?)?$');
static Future<Version> resolveIn([fs.Directory? checkoutPath]) async {
checkoutPath ??= const LocalFileSystem().directory(flutterRoot);
return resolveFile(
checkoutPath.childDirectory('bin').childDirectory('cache').childFile('flutter.version.json'),
);
}
static Future<Version> resolveFile(fs.File file) async {
if (!file.existsSync()) {
return VersionError._(
'The version logic failed to create the Flutter version file: ${file.path}',
contents: null,
);
}
final Object? json = jsonDecode(await file.readAsString());
if (json is! Map<String, Object?>) {
return VersionError._('The version file was in an unexpected format.', contents: '$json');
}
final version = json['flutterVersion'] as String?;
if (version == null) {
return VersionError._(
'The version file was missing the key "flutterVersion".',
contents: '$json',
);
}
if (version == '0.0.0-unknown') {
return VersionError._(
'The version logic failed to determine the Flutter version.',
contents: version,
);
}
if (!version.contains(_pattern)) {
return VersionError._(
'The version logic generated an invalid version string: "$version".',
contents: version,
);
}
return VersionOk._(version);
}
}
final class VersionError implements Version {
const VersionError._(this.error, {required this.contents});
final String error;
final String? contents;
@override
String toString() {
return error;
}
}
final class VersionOk implements Version {
const VersionOk._(this.version);
final String version;
}