blob: 4c181a0e247f0820d785e4df22bba872eb01b7da [file] [log] [blame]
#!/usr/bin/env dart
// Copyright (c) 2018, 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:collection';
import 'dart:convert';
import 'dart:io';
import 'package:args/args.dart';
import 'bots/results.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 new 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 {
final processResult =
await, arguments, runInShell: runInShell);
if (processResult.exitCode != 0) {
final command =
[executable, ...arguments].map(simpleShellSingleQuote).join(" ");
throw new Exception("Command exited ${processResult.exitCode}: $command\n"
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 {
final process = await Process.start(executable, arguments,
mode: ProcessStartMode.inheritStdio, runInShell: runInShell);
final exitCode = await process.exitCode;
final processResult = new ProcessResult(, exitCode, "", "");
if (processResult.exitCode != 0) {
final command =
[executable, ...arguments].map(simpleShellSingleQuote).join(" ");
throw new Exception("Command exited ${processResult.exitCode}: $command");
return processResult;
/// Returns the operating system of a builder.
String systemOfBuilder(String builder) {
return builder.split("-").firstWhere(
(component) => ["linux", "mac", "win"].contains(component),
orElse: () => null);
/// Returns the product mode of a builder.
String modeOfBuilder(String builder) {
return builder.split("-").firstWhere(
(component) => ["debug", "product", "release"].contains(component),
orElse: () => null);
/// Returns the machine architecture of a builder.
String archOfBuilder(String builder) {
return builder.split("-").firstWhere(
(component) => [
orElse: () => null);
/// Returns the runtime environment of a builder.
String runtimeOfBuilder(String builder) {
return builder.split("-").firstWhere(
(component) => ["chrome", "d8", "edge", "firefox", "ie11", "safari"]
orElse: () => null);
/// Expands a variable in a test matrix step command.
String expandVariable(String string, String variable, String value) {
return string.replaceAll("\${$variable}", value ?? "");
/// Expands all variables in a test matrix step command.
String expandVariables(String string, String builder) {
string = expandVariable(string, "system", systemOfBuilder(builder));
string = expandVariable(string, "mode", modeOfBuilder(builder));
string = expandVariable(string, "arch", archOfBuilder(builder));
string = expandVariable(string, "runtime", runtimeOfBuilder(builder));
return string;
/// 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.
bool resolveNamedConfiguration(
List<String> branches,
List<dynamic> buildersConfigurations,
String requestedBranch,
String requestedNamedConfiguration,
String requestedBuilder,
Set<String> outputNamedConfiguration,
Set<String> outputBuilders) {
bool foundBuilder = false;
for (final builderConfiguration in buildersConfigurations) {
for (final builder in builderConfiguration["builders"]) {
if (requestedBuilder != null && builder != requestedBuilder) {
final branch = branchOfBuilder(builder, branches);
if (branch != requestedBranch) {
if (requestedBuilder == null) {
stderr.writeln("error: Builder $requestedBuilder is on branch $branch "
"rather than $requestedBranch");
stderr.writeln("error: To compare with that branch, use: -B $branch");
return false;
foundBuilder = true;
final steps = (builderConfiguration["steps"] as List).cast<Map>();
final testSteps = steps
.where((step) =>
!step.containsKey("script") || step["script"] == "tools/")
for (final step in testSteps) {
final arguments = step["arguments"]
.map((argument) => expandVariables(argument, builder))
final namedConfiguration = arguments
.firstWhere((argument) => (argument as String).startsWith("-n"))
if (requestedNamedConfiguration == null ||
requestedNamedConfiguration == namedConfiguration) {
if (requestedBuilder != null && !foundBuilder) {
stderr.writeln("error: Builder $requestedBuilder doesn't exist");
return false;
if (requestedBuilder != null &&
requestedNamedConfiguration == null &&
outputNamedConfiguration.isEmpty) {
stderr.writeln("error: Builder $requestedBuilder isn't testing any named "
return false;
if (requestedBuilder != null &&
requestedNamedConfiguration != null &&
outputNamedConfiguration.isEmpty) {
stderr.writeln("error: The builder $requestedBuilder isn't testing the "
"named configuration $requestedNamedConfiguration");
return false;
if (requestedNamedConfiguration != null && outputBuilders.isEmpty) {
stderr.writeln("error: The named configuration "
"$requestedNamedConfiguration isn't tested on any builders");
return false;
return true;
/// Locates the merge base between head and the [branch] on the given [remote].
/// If a particular [commit] was requested, use that.
Future<String> findMergeBase(
String commit, String remote, String branch) async {
if (commit != null) {
return commit;
final arguments = ["merge-base", "$remote/$branch", "HEAD"];
final result =
await"git", arguments, runInShell: Platform.isWindows);
if (result.exitCode != 0) {
throw new Exception("Failed to run: git ${arguments.join(' ')}\n"
return LineSplitter.split(result.stdout).first;
/// Exception thrown when looking up the build for a commit failed.
class CommitNotBuiltException implements Exception {
final String reason;
String toString() => reason;
/// The result after searching for a build of a commit.
class BuildSearchResult {
final int build;
final String commit;
BuildSearchResult(, 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 {
final requestUrl = Uri.parse(
final client = new HttpClient();
final request = await client.getUrl(requestUrl);
final response = await request.close();
final Map<String, dynamic> object = await response
.transform(new Utf8Decoder())
.transform(new JsonDecoder())
final builds = object["builds"];
if (builds == null || builds.isEmpty) {
throw new CommitNotBuiltException(
"Builder $builder hasn't built commit $commit");
final build = builds.last;
final tags = (build["tags"] as List).cast<String>();
final buildAddressTag =
tags.firstWhere((tag) => tag.startsWith("build_address:"));
final buildAddress = buildAddressTag.substring("build_address:".length);
if (build["status"] != "COMPLETED") {
throw new CommitNotBuiltException(
"Build $buildAddress isn't completed yet");
return new 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...");
final int limit = 25;
final arguments = [
final processResult = await"git", arguments, runInShell: true);
if (processResult.exitCode != 0) {
throw new Exception("Failed to list potential commits: git $arguments\n"
"exitCode: ${processResult.exitCode}\n"
"stdout: ${processResult.stdout}\n"
"stdout: ${processResult.stderr}\n");
for (final fallbackCommit in LineSplitter.split(processResult.stdout)) {
try {
return await searchForBuild(builder, fallbackCommit);
} catch (e) {
"Warning: Searching for inexact baseline build: $e, continuing...");
throw new CommitNotBuiltException(
"Failed to locate approximate baseline results for "
"$commit in past $limit commits");
void main(List<String> args) async {
final parser = new ArgParser();
abbr: "b", help: "Run tests like on the given builder");
abbr: "B",
help: "Select the builders building this branch",
defaultsTo: "master");
parser.addOption("commit", abbr: "C", help: "Compare with this commit");
help: "Re-run failing newly tests $deflakingCount times.");
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");
help: "Output list of configurations.", negatable: false);
abbr: "n",
help: "The named test configuration that supplies the\nvalues for all "
"test options, specifying how tests\nshould be run.");
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 "
abbr: "R",
help: "Compare with this remote and git branch",
defaultsTo: "origin");
parser.addFlag("help", help: "Show the program usage.", negatable: false);
final options = parser.parse(args);
if (options["help"] ||
(options["builder"] == null &&
options["named-configuration"] == null &&
!options["list-configurations"])) {
Usage: test.dart -b [BUILDER] -n [CONFIGURATION] [OPTION]... [--]
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 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
if (options["list-configurations"]) {
final process = await Process.start(
"python", ["tools/", "--list-configurations"],
mode: ProcessStartMode.inheritStdio, runInShell: Platform.isWindows);
exitCode = await process.exitCode;
// Locate
gsutilPy =
// Load the test matrix.
final scriptPath = Platform.script.toFilePath();
final testMatrixPath =
scriptPath.substring(0, scriptPath.length - "test.dart".length) +
final testMatrix = jsonDecode(await new File(testMatrixPath).readAsString());
final branches = (testMatrix["branches"] as List).cast<String>();
final buildersConfigurations =
testMatrix["builder_configurations"] as List<dynamic>;
// Determine what named configuration to run and which builders to download
// existing results from.
final namedConfigurations = new SplayTreeSet<String>();
final builders = new SplayTreeSet<String>();
if (!resolveNamedConfiguration(
builders)) {
exitCode = 1;
if (namedConfigurations.length > 1) {
final builder = builders.single;
"error: The builder $builder is testing multiple named configurations");
"error: Please select the desired named configuration using -n:");
for (final namedConfiguration in namedConfigurations) {
stderr.writeln(" -n $namedConfiguration");
exitCode = 1;
final namedConfiguration = namedConfigurations.single;
final localConfiguration =
options["local-configuration"] as String ?? namedConfiguration;
for (final builder in builders) {
if (localConfiguration != namedConfiguration) {
print("Testing the named configuration $localConfiguration "
"compared with builder $builder's configuration $namedConfiguration");
} else {
print("Testing the named configuration $localConfiguration "
"compared with builder $builder");
// Find out where the current HEAD branched.
final commit = await findMergeBase(
options["commit"], options["remote"], options["branch"]);
print("Base commit is $commit");
// Store the downloaded results and our test results in a temporary directory.
final outDirectory = await Directory.systemTemp.createTemp("test.dart.");
try {
final mergedResults = <String, Map<String, dynamic>>{};
final mergedFlaky = <String, Map<String, dynamic>>{};
// Use the buildbucket API to search for builds of the right commit.
final inexactBuilds = new SplayTreeMap<String, String>();
for (final builder in builders) {
// Download the previous results and flakiness info from cloud storage.
print("Finding build on builder $builder to compare with...");
final buildSearchResult =
await searchForApproximateBuild(builder, commit);
if (buildSearchResult.commit != commit) {
print("Warning: Using commit ${buildSearchResult.commit} "
"as baseline instead of $commit for $builder");
inexactBuilds[builder] = buildSearchResult.commit;
final buildNumber =;
print("Downloading results from builder $builder build $buildNumber...");
await cpGsutil(buildFileCloudPath(builder, buildNumber, "results.json"),
if (!options["report-flakes"]) {
await cpGsutil(buildFileCloudPath(builder, buildNumber, "flaky.json"),
print("Downloaded baseline results from builder $builder");
// Merge the results for the builders.
if (builders.length > 1) {
.addAll(await loadResultsMap("${outDirectory.path}/previous.json"));
if (!options["report-flakes"]) {
.addAll(await loadResultsMap("${outDirectory.path}/flaky.json"));
// Write out the merged results for the builders.
if (builders.length > 1) {
await new File("${outDirectory.path}/previous.json").writeAsString( => jsonEncode(data) + "\n").join(""));
// Ensure that there is a flaky.json even if it wasn't downloaded.
if (builders.length > 1 || options["report-flakes"]) {
await new File("${outDirectory.path}/flaky.json").writeAsString( => jsonEncode(data) + "\n").join(""));
// Override the named configuration in the baseline data if needed.
if (namedConfiguration != localConfiguration) {
for (final path in [
]) {
final results = await loadResultsMap(path);
final records = results.values
.where((r) => r["configuration"] == namedConfiguration)
..forEach((r) => r["configuration"] = localConfiguration);
await new File(path).writeAsString( => jsonEncode(data) + "\n").join(""));
// Run the tests.
final arguments = [
print("".padLeft(80, "="));
print("Running tests");
print("".padLeft(80, "="));
await runProcessInheritStdio("python", ["tools/", ...arguments],
runInShell: Platform.isWindows);
if (options["deflake"]) {
await deflake(outDirectory, localConfiguration,;
// Write out the final comparison.
print("".padLeft(80, "="));
print("Test Results");
print("".padLeft(80, "="));
final compareOutput = await runProcess(Platform.resolvedExecutable, [
if (compareOutput.stdout == "") {
print("There were no test failures.");
} else {
if (inexactBuilds.isNotEmpty) {
inexactBuilds.forEach((String builder, String inexactCommit) => 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);
void deflake(Directory outDirectory, String localConfiguration,
List<String> testPyArgs) async {
// Find the list of tests to deflake.
final deflakeListOutput = await runProcess(Platform.resolvedExecutable, [
final deflakeListPath = "${outDirectory.path}/deflake.list";
final deflakeListFile = new File(deflakeListPath);
await deflakeListFile.writeAsString(deflakeListOutput.stdout);
// Deflake the changed tests.
final deflakingResultsPaths = <String>[];
for (int i = 1; deflakeListOutput.stdout != "" && i <= deflakingCount; i++) {
print("".padLeft(80, "="));
print("Running deflaking iteration $i");
print("".padLeft(80, "="));
final deflakeDirectory = new Directory("${outDirectory.path}/$i");
await deflakeDirectory.create();
final deflakeArguments = <String>[
await runProcessInheritStdio(
"python", ["tools/", ...deflakeArguments],
runInShell: Platform.isWindows);
// Update the flakiness information based on what we've learned.
print("Updating flakiness information...");
await runProcess(Platform.resolvedExecutable, [