blob: e0843da0b19fc3db220982929bdf0dc205ab9e33 [file] [log] [blame]
// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
// Run tests like on the given builder and/or named configuration.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:args/args.dart';
import 'package:smith/smith.dart';
import 'bot_results.dart';
import 'src/options.dart';
const int deflakingCount = 5;
/// Quotes a string in shell single quote mode. This function produces a single
/// shell argument that evaluates to the exact string provided, handling any
/// special characters in the input string. Shell single quote mode works uses
/// the single quote character as the delimiter and uses the characters
/// in-between verbatim without any special processing. To insert the single
/// quote character itself, escape single quote mode, insert an escaped single
/// quote, and then return to single quote mode.
///
/// Examples:
/// foo becomes 'foo'
/// foo bar becomes 'foo bar'
/// foo\ bar becomes 'foo\ bar'
/// foo's bar becomes 'foo '\''s bar'
/// foo "b"ar becomes 'foo "b"'
/// foo
/// bar becomes 'foo
/// bar'
String shellSingleQuote(String string) {
return "'${string.replaceAll("'", "'\\''")}'";
}
/// Like [shellSingleQuote], but if the string only contains safe ASCII
/// characters, don't quote it. Note that it's not always safe to omit the
/// quotes even if the string only has safe characters, as doing so might match
/// a shell keyword or a shell builtin in the first argument in a command. It
/// should be safe to use this for the second argument onwards in a command.
String simpleShellSingleQuote(String string) {
return RegExp(r"^[a-zA-Z0-9%+,./:_-]*$").hasMatch(string)
? string
: shellSingleQuote(string);
}
/// Runs a process and exits likewise if the process exits non-zero.
Future<ProcessResult> runProcess(String executable, List<String> arguments,
{bool runInShell = false}) async {
var processResult =
await Process.run(executable, arguments, runInShell: runInShell);
if (processResult.exitCode != 0) {
var command =
[executable, ...arguments].map(simpleShellSingleQuote).join(" ");
throw Exception("Command exited ${processResult.exitCode}: $command\n"
"${processResult.stdout}\n${processResult.stderr}");
}
return processResult;
}
/// Runs a process and exits likewise if the process exits non-zero, but let the
/// child process inherit out stdio handles.
Future<ProcessResult> runProcessInheritStdio(
String executable, List<String> arguments,
{bool runInShell = false}) async {
var process = await Process.start(executable, arguments,
mode: ProcessStartMode.inheritStdio, runInShell: runInShell);
var exitCode = await process.exitCode;
var processResult = ProcessResult(process.pid, exitCode, "", "");
if (processResult.exitCode != 0) {
var command =
[executable, ...arguments].map(simpleShellSingleQuote).join(" ");
throw Exception("Command exited ${processResult.exitCode}: $command");
}
return processResult;
}
/// Finds the branch of a builder given the list of branches.
String branchOfBuilder(String builder, List<String> branches) {
return branches.where((branch) => branch != "master").firstWhere(
(branch) => builder.endsWith("-$branch"),
orElse: () => "master");
}
/// Finds the named configuration to test according to the test matrix
/// information and the command line options.
Map<String, Set<Builder>> resolveNamedConfigurations(
TestMatrix testMatrix,
String requestedBranch,
List<String> requestedNamedConfigurations,
String requestedBuilder) {
assert(requestedBranch != null);
var testedConfigurations = <String, Set<Builder>>{};
var foundBuilder = false;
for (var builder in testMatrix.builders) {
if (requestedBuilder != null && builder.name != requestedBuilder) {
continue;
}
var branch = branchOfBuilder(builder.name, testMatrix.branches);
if (branch != requestedBranch) {
if (requestedBuilder == null) {
continue;
}
stderr.writeln("error: Builder $requestedBuilder is on branch $branch "
"rather than $requestedBranch");
stderr.writeln("error: To compare with that branch, use: -B $branch");
return null;
}
foundBuilder = true;
for (var step in builder.steps.where((step) => step.isTestStep)) {
var testedConfiguration = step.testedConfiguration;
if (testedConfiguration == null) {
// This test step does not use a configuration; for example,
// because it is a simple script that does not produce results.
continue;
}
var namedConfiguration = testedConfiguration.name;
if (requestedNamedConfigurations.isEmpty ||
requestedNamedConfigurations.contains(namedConfiguration)) {
testedConfigurations
.putIfAbsent(namedConfiguration, () => {})
.add(builder);
}
}
}
if (requestedBuilder != null && !foundBuilder) {
stderr.writeln("error: Builder $requestedBuilder doesn't exist");
return null;
}
if (requestedBuilder != null &&
requestedNamedConfigurations.isEmpty &&
testedConfigurations.isEmpty) {
stderr.writeln("error: Builder $requestedBuilder isn't testing any named "
"configurations");
return null;
}
if (requestedNamedConfigurations.isNotEmpty) {
var hasUntestedConfiguration = false;
for (var requestedConfiguration in requestedNamedConfigurations) {
if (!testedConfigurations.containsKey(requestedConfiguration)) {
var builder = requestedBuilder != null
? "builder $requestedBuilder"
: "any builder";
stderr.writeln("error: The named configuration "
"$requestedConfiguration isn't tested on $builder");
hasUntestedConfiguration = true;
}
}
if (hasUntestedConfiguration) {
return null;
}
}
return testedConfigurations;
}
/// Locates the merge base between head and the [branch] on the given [remote].
Future<String> findMergeBase(String remote, String branch) async {
var arguments = ["merge-base", "$remote/$branch", "HEAD"];
var result =
await Process.run("git", arguments, runInShell: Platform.isWindows);
var stdout = result.stdout as String;
var stderr = result.stderr as String;
if (result.exitCode != 0) {
throw Exception("Failed to run: git ${arguments.join(' ')}\n"
"stdout:\n$stdout\n"
"stderr:\n$stderr\n");
}
return LineSplitter.split(stdout).first;
}
/// Exception thrown when looking up the build for a commit failed.
class CommitNotBuiltException implements Exception {
final String reason;
CommitNotBuiltException(this.reason);
String toString() => reason;
}
/// The result after searching for a build of a commit.
class BuildSearchResult {
final int build;
final String commit;
BuildSearchResult(this.build, this.commit);
}
/// Locates the build number of the [commit] on the [builder], or throws an
/// exception if the builder hasn't built the commit.
Future<BuildSearchResult> searchForBuild(String builder, String commit) async {
var requestUrl = Uri.parse(
"https://cr-buildbucket.appspot.com/_ah/api/buildbucket/v1/search"
"?bucket=luci.dart.ci.sandbox"
"&tag=builder%3A$builder"
"&tag=buildset%3Acommit%2Fgit%2F$commit"
"&fields=builds(status%2Ctags%2Curl)");
var client = HttpClient();
var request = await client.getUrl(requestUrl);
var response = await request.close();
var object = await response
.cast<List<int>>()
.transform(const Utf8Decoder())
.transform(const JsonDecoder())
.first as Map<String, dynamic>;
client.close();
var builds = object["builds"] as List<dynamic>;
if (builds == null || builds.isEmpty) {
throw CommitNotBuiltException(
"Builder $builder hasn't built commit $commit");
}
var build = builds.last;
var tags = (build["tags"] as List).cast<String>();
var buildAddressTag =
tags.firstWhere((tag) => tag.startsWith("build_address:"));
var buildAddress = buildAddressTag.substring("build_address:".length);
if (build["status"] != "COMPLETED") {
throw CommitNotBuiltException("Build $buildAddress isn't completed yet");
}
return BuildSearchResult(int.parse(buildAddress.split("/").last), commit);
}
Future<BuildSearchResult> searchForApproximateBuild(
String builder, String commit) async {
try {
return await searchForBuild(builder, commit);
} on CommitNotBuiltException catch (e) {
print("Warning: $e, searching for an inexact previous build...");
var limit = 25;
var arguments = [
"rev-list",
"$commit~$limit..$commit~1",
"--first-parent",
"--topo-order"
];
var processResult = await Process.run("git", arguments, runInShell: true);
if (processResult.exitCode != 0) {
throw Exception("Failed to list potential commits: git $arguments\n"
"exitCode: ${processResult.exitCode}\n"
"stdout: ${processResult.stdout}\n"
"stdout: ${processResult.stderr}\n");
}
for (var fallbackCommit
in LineSplitter.split(processResult.stdout as String)) {
try {
return await searchForBuild(builder, fallbackCommit);
} catch (e) {
print(
"Warning: Searching for inexact baseline build: $e, continuing...");
}
}
throw CommitNotBuiltException(
"Failed to locate approximate baseline results for "
"$commit in past $limit commits");
}
}
void overrideConfiguration(Map<String, Map<String, dynamic>> results,
String configuration, String newConfiguration) {
results.forEach((String key, Map<String, dynamic> result) {
if (result["configuration"] == configuration) {
result["configuration"] = newConfiguration;
}
});
}
void printUsage(ArgParser parser, {String error, bool printOptions = false}) {
if (error != null) {
print("$error\n");
exitCode = 1;
}
print("""
Usage: test.dart -b [BUILDER] -n [CONFIGURATION] [OPTION]... [--]
[TEST.PY OPTION]... [SELECTOR]...
Run tests and compare with the results on the given builder. Either the -n or
the -b option, or both, must be used. Any options following -- and non-option
arguments will be forwarded to test.py invocations. The specified named
configuration's results will be downloaded from the specified builder. If only a
named configuration is specified, the results are downloaded from the
appropriate builders. If only a builder is specified, the default named
configuration is used if the builder only has a single named configuration.
Otherwise the available named configurations are listed.
See the documentation at https://goto.google.com/dart-status-file-free-workflow
""");
if (printOptions) {
print(parser.usage);
} else {
print("Run test.dart --help to see all options.");
}
}
Future<void> runTests(List<String> args) async {
var parser = ArgParser();
parser.addOption("builder",
abbr: "b", help: "Run tests like on the given builder");
parser.addOption("branch",
abbr: "B",
help: "Select the builders building this branch",
defaultsTo: "master");
parser.addOption("commit", abbr: "C", help: "Compare with this commit");
parser.addFlag("deflake",
help: "Re-run failing newly tests $deflakingCount times.");
parser.addFlag("report-flakes",
help: "Report test failures for tests known to be flaky.\n"
"This ignores all flakiness data from CI but flakes\n"
"detected by --deflake will remain hidden");
parser.addFlag("list-configurations",
help: "Output list of configurations.", negatable: false);
parser.addMultiOption("named-configuration",
abbr: "n",
help: "The named test configuration(s) that supplies the\nvalues for all "
"test options, specifying how tests\nshould be run.");
parser.addOption("local-configuration",
abbr: "N",
help: "Use a different named configuration for local\ntesting than the "
"named configuration the baseline\nresults were downloaded for. The "
"results may be\ninexact if the baseline configuration is "
"different.");
parser.addOption("remote",
abbr: "R",
help: "Compare with this remote and git branch",
defaultsTo: "origin");
parser.addFlag("help", help: "Show the program usage.", negatable: false);
ArgResults options;
try {
options = parser.parse(args);
} on FormatException catch (exception) {
printUsage(parser, error: exception.message);
return;
}
if (options["help"] as bool) {
printUsage(parser, printOptions: true);
return;
}
if (options["list-configurations"] as bool) {
listConfigurations({"system": "all"});
return;
}
var requestedBuilder = options["builder"] as String;
var requestedNamedConfigurations =
(options["named-configuration"] as List).cast<String>();
var localConfiguration = options["local-configuration"] as String;
if (requestedBuilder == null && requestedNamedConfigurations.isEmpty) {
printUsage(parser,
error: "Please specify either a configuration (-n) or "
"a builder (-b)");
return;
}
if (localConfiguration != null && requestedNamedConfigurations.length > 1) {
printUsage(parser,
error: "Local configuration (-N) can only be used with a"
" single named configuration (-n)");
return;
}
// Locate gsutil.py.
gsutilPy =
Platform.script.resolve("../third_party/gsutil/gsutil.py").toFilePath();
// Load the test matrix.
var testMatrixPath = Platform.script.resolve("bots/test_matrix.json");
var testMatrix = TestMatrix.fromPath(testMatrixPath.toFilePath());
// Determine what named configuration to run and which builders to download
// existing results from.
var testedConfigurations = resolveNamedConfigurations(
testMatrix,
options["branch"] as String,
requestedNamedConfigurations,
requestedBuilder);
if (testedConfigurations == null) {
// No valid configuration could be found. The error has already been
// reported by [resolveConfigurations].
exitCode = 1;
return;
}
var namedConfigurations = testedConfigurations.keys.toSet().toList();
var builders =
testedConfigurations.values.expand((builders) => builders).toSet();
// Print information about the resolved builders to compare with.
for (var namedConfiguration in namedConfigurations) {
var testedBuilders = testedConfigurations[namedConfiguration];
var onWhichBuilders = testedBuilders.length == 1
? "builder ${testedBuilders.single.name}"
: "builders${testedBuilders.map((b) => "\n ${b.name}").join()}";
if (localConfiguration != null) {
print("Testing named configuration $localConfiguration "
"compared with configuration $namedConfiguration "
"on $onWhichBuilders");
} else {
print("Testing named configuration $namedConfiguration "
"compared with $onWhichBuilders");
}
}
// Use given commit or find out where the current HEAD branched.
var commit = options["commit"] as String ??
await findMergeBase(
options["remote"] as String, options["branch"] as String);
print("Base commit is $commit");
// Store the downloaded results and our test results in a temporary directory.
var outDirectory = await Directory.systemTemp.createTemp("test.dart.");
try {
var tasks = <Future>[];
var needsConfigurationOverride = localConfiguration != null &&
localConfiguration != namedConfigurations.single;
var needsMerge = builders.length > 1;
var inexactBuilds = <String, String>{};
var previousFileName = "previous.json";
var flakyFileName = "flaky.json";
var downloadNumber = 0;
// Download the previous results and flakiness info from cloud storage.
for (var builder in builders) {
var builderName = builder.name;
if (needsMerge) {
previousFileName = "previous-$downloadNumber.json";
flakyFileName = "flaky-$downloadNumber.json";
downloadNumber++;
}
// Use the buildbucket API to search for builds of the right commit.
print("Finding build on builder $builderName to compare with...");
var buildSearchResult =
await searchForApproximateBuild(builderName, commit);
if (buildSearchResult.commit != commit) {
print("Warning: Using commit ${buildSearchResult.commit} "
"as baseline instead of $commit for $builderName");
inexactBuilds[builderName] = buildSearchResult.commit;
}
var buildNumber = buildSearchResult.build.toString();
print("Downloading results from builder $builderName "
"build $buildNumber...");
tasks.add(cpGsutil(
buildFileCloudPath(builderName, buildNumber, "results.json"),
"${outDirectory.path}/$previousFileName"));
if (!(options["report-flakes"] as bool)) {
tasks.add(cpGsutil(
buildFileCloudPath(builderName, buildNumber, "flaky.json"),
"${outDirectory.path}/$flakyFileName"));
}
}
// Run the tests.
var configurationsToRun = localConfiguration != null
? <String>[localConfiguration]
: namedConfigurations;
print("".padLeft(80, "="));
print("Running tests");
print("".padLeft(80, "="));
await runProcessInheritStdio(
"python",
[
"tools/test.py",
"--named-configuration=${configurationsToRun.join(",")}",
"--output-directory=${outDirectory.path}",
"--clean-exit",
"--silent-failures",
"--write-results",
"--write-logs",
...options.rest,
],
runInShell: Platform.isWindows);
// Wait for the downloads and the test run to complete.
await Future.wait(tasks);
// Merge the results and flaky data downloaded from the builders.
var mergedResults = <String, Map<String, dynamic>>{};
var mergedFlaky = <String, Map<String, dynamic>>{};
if (needsMerge || needsConfigurationOverride) {
for (var i = 0; i < downloadNumber; ++i) {
previousFileName = needsMerge ? "previous-$i.json" : "previous.json";
var results =
await loadResultsMap("${outDirectory.path}/$previousFileName");
if (needsConfigurationOverride) {
overrideConfiguration(
results, namedConfigurations.single, localConfiguration);
}
mergedResults.addAll(results);
if (!(options["report-flakes"] as bool)) {
flakyFileName = needsMerge ? "flaky-$i.json" : "flaky.json";
var flakyTests =
await loadResultsMap("${outDirectory.path}/$flakyFileName");
if (needsConfigurationOverride) {
overrideConfiguration(
flakyTests, namedConfigurations.single, localConfiguration);
}
mergedFlaky.addAll(flakyTests);
}
}
}
// Write out the merged results for the builders.
if (needsMerge || needsConfigurationOverride) {
await File("${outDirectory.path}/previous.json").writeAsString(
mergedResults.values.map((data) => jsonEncode(data) + "\n").join(""));
}
// Ensure that there is a flaky.json even if it wasn't downloaded.
if (needsMerge ||
needsConfigurationOverride ||
options["report-flakes"] as bool) {
await File("${outDirectory.path}/flaky.json").writeAsString(
mergedFlaky.values.map((data) => jsonEncode(data) + "\n").join(""));
}
// Deflake results of the tests if required.
if (options["deflake"] as bool) {
await deflake(outDirectory, configurationsToRun, options.rest);
}
// Write out the final comparison.
print("".padLeft(80, "="));
print("Test Results");
print("".padLeft(80, "="));
var compareOutput = await runProcess(Platform.resolvedExecutable, [
"tools/bots/compare_results.dart",
"--human",
"--verbose",
"--changed",
"--failing",
"--passing",
"--flakiness-data=${outDirectory.path}/flaky.json",
"--logs=${outDirectory.path}/logs.json",
"${outDirectory.path}/previous.json",
"${outDirectory.path}/results.json",
]);
if (compareOutput.stdout == "") {
print("There were no test failures.");
} else {
stdout.write(compareOutput.stdout);
}
if (inexactBuilds.isNotEmpty) {
print("");
var builders = inexactBuilds.keys.toList()..sort();
for (var builder in builders) {
var inexactCommit = inexactBuilds[builder];
print("Warning: Results may be inexact because commit $inexactCommit "
"was used as the baseline for $builder instead of $commit");
}
}
} finally {
await outDirectory.delete(recursive: true);
}
}
Future<void> deflake(Directory outDirectory, List<String> configurations,
List<String> testPyArgs) async {
// Find the list of tests to deflake.
var deflakeListOutput = await runProcess(Platform.resolvedExecutable, [
"tools/bots/compare_results.dart",
"--changed",
"--failing",
"--passing",
"--flakiness-data=${outDirectory.path}/flaky.json",
"${outDirectory.path}/previous.json",
"${outDirectory.path}/results.json",
]);
var deflakeListPath = "${outDirectory.path}/deflake.list";
var deflakeListFile = File(deflakeListPath);
await deflakeListFile.writeAsString(deflakeListOutput.stdout as String);
// Deflake the changed tests.
var deflakingResultsPaths = <String>[];
for (var i = 1; deflakeListOutput.stdout != "" && i <= deflakingCount; i++) {
print("".padLeft(80, "="));
print("Running deflaking iteration $i");
print("".padLeft(80, "="));
var deflakeDirectory = Directory("${outDirectory.path}/$i");
await deflakeDirectory.create();
var deflakeArguments = [
"--named-configuration=${configurations.join(",")}",
"--output-directory=${deflakeDirectory.path}",
"--clean-exit",
"--silent-failures",
"--write-results",
"--test-list=$deflakeListPath",
...testPyArgs,
];
await runProcessInheritStdio(
"python", ["tools/test.py", ...deflakeArguments],
runInShell: Platform.isWindows);
deflakingResultsPaths.add("${deflakeDirectory.path}/results.json");
}
// Update the flakiness information based on what we've learned.
print("Updating flakiness information...");
await runProcess(Platform.resolvedExecutable, [
"tools/bots/update_flakiness.dart",
"--input=${outDirectory.path}/flaky.json",
"--output=${outDirectory.path}/flaky.json",
"${outDirectory.path}/results.json",
...deflakingResultsPaths,
]);
}