blob: ebe3add019dd0cdf1a258c3e923b600a200413a9 [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.
import 'dart:async';
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]..addAll(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]..addAll(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;
/// 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;
/// Locates the build number of the [commit] on the [builder], or throws an
/// exception if the builder hasn't built the commit.
Future<int> buildNumberOfCommit(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 Exception("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 Exception("Build $buildAddress isn't completed yet");
return int.parse(buildAddress.split("/").last);
void main(List<String> args) async {
final parser = new ArgParser();
abbr: "b", help: "Run tests like on the given buider");
abbr: "B",
help: "Select the builders building this branch",
defaultsTo: "master");
parser.addOption("commit", abbr: "C", help: "Compare with this commit");
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) {
Usage: test.dart -b [BUILDER] [OPTION]...
Run tests and compare with the results on the given builder.
// Locate
gsutilPy =
final builder = options["builder"];
// Find out where the current HEAD branched.
final commit = await findMergeBase(
options["commit"], options["remote"], options["branch"]);
print("Base commit is $commit");
// Use the buildbucket API to search for builds of the right rcommit.
print("Finding build to compare with...");
final buildNumber = await buildNumberOfCommit(builder, commit);
print("Comparing with build $buildNumber on $builder");
final outDirectory = await Directory.systemTemp.createTemp("test.dart.");
try {
// Download the previous results and flakiness info from cloud storage.
print("Downloading previous results...");
await cpGsutil(
buildFileCloudPath(builder, buildNumber.toString(), "results.json"),
await cpGsutil(
buildFileCloudPath(builder, buildNumber.toString(), "flaky.json"),
print("Downloaded previous results");
// 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());
// Find the appropriate steps.
final buildersConfigurations = testMatrix["builder_configurations"];
final builderConfiguration = buildersConfigurations.firstWhere(
(builderConfiguration) =>
(builderConfiguration["builders"] as List).contains(builder));
final steps = (builderConfiguration["steps"] as List).cast<Map>();
final testSteps = steps
.where((step) =>
!step.containsKey("script") || step["script"] == "tools/")
// Run each step like the builder would, deflaking tests that need it.
final stepResultsPaths = <String>[];
final stepLogsPaths = <String>[];
for (int stepIndex = 0; stepIndex < testSteps.length; stepIndex++) {
// Run the test step.
final testStep = testSteps[stepIndex];
final stepName = testStep["name"];
final stepDirectory = new Directory("${outDirectory.path}/$stepIndex");
await stepDirectory.create();
final stepArguments = testStep["arguments"]
.map((argument) => expandVariables(argument, builder))
final fullArguments = <String>[]
print("".padLeft(80, "="));
print("$stepName: Running tests");
print("".padLeft(80, "="));
await runProcessInheritStdio(
"python", ["tools/"]..addAll(fullArguments),
runInShell: Platform.isWindows);
// Find the list of tests to deflake.
final deflakeListOutput = await runProcess(Platform.resolvedExecutable, [
final deflakeListPath = "${stepDirectory.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("$stepName: Running deflaking iteration $i");
print("".padLeft(80, "="));
final deflakeDirectory = new Directory("${stepDirectory.path}/$i");
await deflakeDirectory.create();
final deflakeArguments = <String>[]
await runProcessInheritStdio(
"python", ["tools/"]..addAll(deflakeArguments),
runInShell: Platform.isWindows);
// Update the flakiness information based on what we've learned.
print("$stepName: Updating flakiness information");
await runProcess(
// Collect all the results from all the steps.
await new File("${outDirectory.path}/results.json").writeAsString(
.map((path) => new File(path).readAsStringSync())
// Collect all the logs from all the steps.
await new File("${outDirectory.path}/logs.json").writeAsString(stepLogsPaths
.map((path) => new File(path).readAsStringSync())
// 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 {
} finally {
await outDirectory.delete(recursive: true);