#!/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 runProcess(String executable, List arguments, {bool runInShell = false}) async { final processResult = await Process.run(executable, arguments, runInShell: runInShell); if (processResult.exitCode != 0) { final command = [executable, ...arguments].map(simpleShellSingleQuote).join(" "); throw new 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 runProcessInheritStdio( String executable, List 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(process.pid, 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) => [ "arm", "arm64", "armsimdbc", "armsimdbc64", "ia32", "simarm", "simarm64", "simdbc", "simdbc64", "x64", ].contains(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"] .contains(component), 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 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 branches, List buildersConfigurations, String requestedBranch, String requestedNamedConfiguration, String requestedBuilder, Set outputNamedConfiguration, Set outputBuilders) { bool foundBuilder = false; for (final builderConfiguration in buildersConfigurations) { for (final builder in builderConfiguration["builders"]) { if (requestedBuilder != null && builder != requestedBuilder) { continue; } final branch = branchOfBuilder(builder, 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 false; } foundBuilder = true; final steps = (builderConfiguration["steps"] as List).cast(); final testSteps = steps .where((step) => !step.containsKey("script") || step["script"] == "tools/test.py") .toList(); for (final step in testSteps) { final arguments = step["arguments"] .map((argument) => expandVariables(argument, builder)) .toList(); final namedConfiguration = arguments .firstWhere((argument) => (argument as String).startsWith("-n")) .substring(2); if (requestedNamedConfiguration == null || requestedNamedConfiguration == namedConfiguration) { outputNamedConfiguration.add(namedConfiguration); outputBuilders.add(builder); } } } } 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 " "configurations"); 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 findMergeBase( String commit, String remote, String branch) async { if (commit != null) { return commit; } final arguments = ["merge-base", "$remote/$branch", "HEAD"]; final result = await Process.run("git", arguments, runInShell: Platform.isWindows); if (result.exitCode != 0) { throw new Exception("Failed to run: git ${arguments.join(' ')}\n" "stdout:\n${result.stdout}\n" "stderr:\n${result.stderr}\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; 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 searchForBuild(String builder, String commit) async { final 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)"); final client = new HttpClient(); final request = await client.getUrl(requestUrl); final response = await request.close(); final Map object = await response .transform(new Utf8Decoder()) .transform(new JsonDecoder()) .first; client.close(); 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(); 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 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 = [ "rev-list", "$commit~$limit..$commit~1", "--first-parent", "--topo-order" ]; final processResult = await Process.run("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) { print( "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 args) async { final parser = new 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.addOption("named-configuration", abbr: "n", help: "The named test configuration 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); final options = parser.parse(args); if (options["help"] || (options["builder"] == null && options["named-configuration"] == null && !options["list-configurations"])) { 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 ${parser.usage}"""); return; } if (options["list-configurations"]) { final process = await Process.start( "python", ["tools/test.py", "--list-configurations"], mode: ProcessStartMode.inheritStdio, runInShell: Platform.isWindows); exitCode = await process.exitCode; return; } // Locate gsutil.py. gsutilPy = Platform.script.resolve("../third_party/gsutil/gsutil.py").toFilePath(); // Load the test matrix. final scriptPath = Platform.script.toFilePath(); final testMatrixPath = scriptPath.substring(0, scriptPath.length - "test.dart".length) + "bots/test_matrix.json"; final testMatrix = jsonDecode(await new File(testMatrixPath).readAsString()); final branches = (testMatrix["branches"] as List).cast(); final buildersConfigurations = testMatrix["builder_configurations"] as List; // Determine what named configuration to run and which builders to download // existing results from. final namedConfigurations = new SplayTreeSet(); final builders = new SplayTreeSet(); if (!resolveNamedConfiguration( branches, buildersConfigurations, options["branch"], options["named-configuration"], options["builder"], namedConfigurations, builders)) { exitCode = 1; return; } if (namedConfigurations.length > 1) { final builder = builders.single; stderr.writeln( "error: The builder $builder is testing multiple named configurations"); stderr.writeln( "error: Please select the desired named configuration using -n:"); for (final namedConfiguration in namedConfigurations) { stderr.writeln(" -n $namedConfiguration"); } exitCode = 1; return; } 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 = >{}; final mergedFlaky = >{}; // Use the buildbucket API to search for builds of the right commit. final inexactBuilds = new SplayTreeMap(); 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 = buildSearchResult.build.toString(); print("Downloading results from builder $builder build $buildNumber..."); await cpGsutil(buildFileCloudPath(builder, buildNumber, "results.json"), "${outDirectory.path}/previous.json"); if (!options["report-flakes"]) { await cpGsutil(buildFileCloudPath(builder, buildNumber, "flaky.json"), "${outDirectory.path}/flaky.json"); } print("Downloaded baseline results from builder $builder"); // Merge the results for the builders. if (builders.length > 1) { mergedResults .addAll(await loadResultsMap("${outDirectory.path}/previous.json")); if (!options["report-flakes"]) { mergedFlaky .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( mergedResults.values.map((data) => 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( mergedFlaky.values.map((data) => jsonEncode(data) + "\n").join("")); } // Override the named configuration in the baseline data if needed. if (namedConfiguration != localConfiguration) { for (final path in [ "${outDirectory.path}/previous.json", "${outDirectory.path}/flaky.json" ]) { final results = await loadResultsMap(path); final records = results.values .where((r) => r["configuration"] == namedConfiguration) .toList() ..forEach((r) => r["configuration"] = localConfiguration); await new File(path).writeAsString( records.map((data) => jsonEncode(data) + "\n").join("")); } } // Run the tests. final arguments = [ "--named-configuration=$localConfiguration", "--output-directory=${outDirectory.path}", "--clean-exit", "--silent-failures", "--write-results", "--write-logs", ...options.rest, ]; print("".padLeft(80, "=")); print("Running tests"); print("".padLeft(80, "=")); await runProcessInheritStdio("python", ["tools/test.py", ...arguments], runInShell: Platform.isWindows); if (options["deflake"]) { await deflake(outDirectory, localConfiguration, options.rest); } // Write out the final comparison. print("".padLeft(80, "=")); print("Test Results"); print("".padLeft(80, "=")); final 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(""); 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 testPyArgs) async { // Find the list of tests to deflake. final 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", ]); final deflakeListPath = "${outDirectory.path}/deflake.list"; final deflakeListFile = new File(deflakeListPath); await deflakeListFile.writeAsString(deflakeListOutput.stdout); // Deflake the changed tests. final deflakingResultsPaths = []; 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 = [ "--named-configuration=$localConfiguration", "--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, ]); }