Move TestStrategy to lib.exec package.
This is part of refactoring test strategy to unify its implementation between
Bazel and Blaze (Google's internal version of Bazel), which should fix several
issues in Bazel.
It's also necessary to untangle lib.rules and lib.exec to enforce proper
layering by separating compilation of these packages, and to provide a minimal
Bazel binary. In particular, no core part of Bazel should depend on any of the
rules, to facilitate moving them out of Bazel / reimplementing them in Skylark
(except for some core rules like test_suite, alias, and genquery).
--
PiperOrigin-RevId: 142151901
MOS_MIGRATED_REVID=142151901
diff --git a/src/main/java/com/google/devtools/build/lib/exec/ExecutionOptions.java b/src/main/java/com/google/devtools/build/lib/exec/ExecutionOptions.java
index 96597c0..dc823f2 100644
--- a/src/main/java/com/google/devtools/build/lib/exec/ExecutionOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/exec/ExecutionOptions.java
@@ -14,16 +14,14 @@
package com.google.devtools.build.lib.exec;
import com.google.devtools.build.lib.actions.ResourceSet;
+import com.google.devtools.build.lib.exec.TestStrategy.TestOutputFormat;
+import com.google.devtools.build.lib.exec.TestStrategy.TestSummaryFormat;
import com.google.devtools.build.lib.packages.TestTimeout;
-import com.google.devtools.build.lib.rules.test.TestStrategy;
-import com.google.devtools.build.lib.rules.test.TestStrategy.TestOutputFormat;
-import com.google.devtools.build.lib.rules.test.TestStrategy.TestSummaryFormat;
import com.google.devtools.build.lib.util.OptionsUtils;
import com.google.devtools.build.lib.vfs.PathFragment;
import com.google.devtools.common.options.Option;
import com.google.devtools.common.options.Options;
import com.google.devtools.common.options.OptionsBase;
-
import java.util.Map;
/**
diff --git a/src/main/java/com/google/devtools/build/lib/exec/StandaloneTestStrategy.java b/src/main/java/com/google/devtools/build/lib/exec/StandaloneTestStrategy.java
new file mode 100644
index 0000000..fb196cb
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/exec/StandaloneTestStrategy.java
@@ -0,0 +1,307 @@
+// Copyright 2014 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.devtools.build.lib.exec;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.devtools.build.lib.actions.ActionExecutionContext;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.BaseSpawn;
+import com.google.devtools.build.lib.actions.EnvironmentalExecException;
+import com.google.devtools.build.lib.actions.ExecException;
+import com.google.devtools.build.lib.actions.ExecutionStrategy;
+import com.google.devtools.build.lib.actions.Executor;
+import com.google.devtools.build.lib.actions.ResourceManager;
+import com.google.devtools.build.lib.actions.ResourceManager.ResourceHandle;
+import com.google.devtools.build.lib.actions.ResourceSet;
+import com.google.devtools.build.lib.actions.Spawn;
+import com.google.devtools.build.lib.actions.SpawnActionContext;
+import com.google.devtools.build.lib.actions.TestExecException;
+import com.google.devtools.build.lib.analysis.RunfilesSupplierImpl;
+import com.google.devtools.build.lib.analysis.config.BinTools;
+import com.google.devtools.build.lib.analysis.config.BuildConfiguration;
+import com.google.devtools.build.lib.events.Event;
+import com.google.devtools.build.lib.events.EventKind;
+import com.google.devtools.build.lib.events.Reporter;
+import com.google.devtools.build.lib.rules.test.TestActionContext;
+import com.google.devtools.build.lib.rules.test.TestResult;
+import com.google.devtools.build.lib.rules.test.TestRunnerAction;
+import com.google.devtools.build.lib.util.io.FileOutErr;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.lib.view.test.TestStatus.BlazeTestStatus;
+import com.google.devtools.build.lib.view.test.TestStatus.TestCase;
+import com.google.devtools.build.lib.view.test.TestStatus.TestResultData;
+import com.google.devtools.common.options.OptionsClassProvider;
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Runs TestRunnerAction actions. */
+@ExecutionStrategy(
+ contextType = TestActionContext.class,
+ name = {"standalone"}
+)
+public class StandaloneTestStrategy extends TestStrategy {
+ // TODO(bazel-team) - add tests for this strategy.
+ public static final String COLLECT_COVERAGE =
+ "external/bazel_tools/tools/test/collect_coverage.sh";
+
+ protected final Path workspace;
+
+ public StandaloneTestStrategy(
+ OptionsClassProvider requestOptions,
+ BinTools binTools,
+ Map<String, String> clientEnv,
+ Path workspace) {
+ super(requestOptions, binTools, clientEnv);
+ this.workspace = workspace;
+ }
+
+ @Override
+ public void exec(TestRunnerAction action, ActionExecutionContext actionExecutionContext)
+ throws ExecException, InterruptedException {
+ Path execRoot = actionExecutionContext.getExecutor().getExecRoot();
+ Path coverageDir = execRoot.getRelative(TestStrategy.getCoverageDirectory(action));
+
+ Path runfilesDir = null;
+ try {
+ runfilesDir =
+ TestStrategy.getLocalRunfilesDirectory(
+ action,
+ actionExecutionContext,
+ binTools,
+ action.getLocalShellEnvironment(),
+ action.isEnableRunfiles());
+ } catch (ExecException e) {
+ throw new TestExecException(e.getMessage());
+ }
+
+ Path testTmpDir =
+ TestStrategy.getTmpRoot(workspace, execRoot, executionOptions)
+ .getChild(getTmpDirName(action.getExecutionSettings().getExecutable().getExecPath()));
+ Path workingDirectory = runfilesDir.getRelative(action.getRunfilesPrefix());
+
+ TestRunnerAction.ResolvedPaths resolvedPaths = action.resolve(execRoot);
+ Map<String, String> env =
+ getEnv(action, execRoot, runfilesDir, testTmpDir, resolvedPaths.getXmlOutputPath());
+ Executor executor = actionExecutionContext.getExecutor();
+
+ try {
+ prepareFileSystem(action, testTmpDir, coverageDir, workingDirectory);
+
+ ResourceSet resources =
+ action.getTestProperties().getLocalResourceUsage(executionOptions.usingLocalTestJobs());
+
+ try (FileOutErr fileOutErr =
+ new FileOutErr(
+ action.getTestLog().getPath(),
+ action
+ .resolve(actionExecutionContext.getExecutor().getExecRoot())
+ .getTestStderr());
+ ResourceHandle handle = ResourceManager.instance().acquireResources(action, resources)) {
+ TestResultData data =
+ execute(
+ actionExecutionContext.withFileOutErr(fileOutErr),
+ env,
+ action,
+ execRoot,
+ runfilesDir);
+ appendStderr(fileOutErr.getOutputPath(), fileOutErr.getErrorPath());
+ finalizeTest(actionExecutionContext, action, data);
+ }
+ } catch (IOException e) {
+ executor.getEventHandler().handle(Event.error("Caught I/O exception: " + e));
+ throw new EnvironmentalExecException("unexpected I/O exception", e);
+ }
+ }
+
+ private Map<String, String> getEnv(
+ TestRunnerAction action, Path execRoot, Path runfilesDir, Path tmpDir, Path xmlOutputPath) {
+ Map<String, String> vars = getDefaultTestEnvironment(action);
+ BuildConfiguration config = action.getConfiguration();
+
+ vars.putAll(config.getLocalShellEnvironment());
+ vars.putAll(action.getTestEnv());
+
+ String tmpDirString;
+ if (tmpDir.startsWith(execRoot)) {
+ tmpDirString = tmpDir.relativeTo(execRoot).getPathString();
+ } else {
+ tmpDirString = tmpDir.getPathString();
+ }
+
+ String testSrcDir = runfilesDir.relativeTo(execRoot).getPathString();
+ vars.put("JAVA_RUNFILES", testSrcDir);
+ vars.put("PYTHON_RUNFILES", testSrcDir);
+ vars.put("TEST_SRCDIR", testSrcDir);
+ vars.put("TEST_TMPDIR", tmpDirString);
+ vars.put("TEST_WORKSPACE", action.getRunfilesPrefix());
+ vars.put("XML_OUTPUT_FILE", xmlOutputPath.relativeTo(execRoot).getPathString());
+ if (!action.isEnableRunfiles()) {
+ vars.put("RUNFILES_MANIFEST_ONLY", "1");
+ }
+
+ PathFragment coverageDir = TestStrategy.getCoverageDirectory(action);
+ if (isCoverageMode(action)) {
+ vars.put("COVERAGE_DIR", coverageDir.toString());
+ vars.put("COVERAGE_OUTPUT_FILE", action.getCoverageData().getExecPathString());
+ }
+
+ return vars;
+ }
+
+ protected TestResultData execute(
+ ActionExecutionContext actionExecutionContext,
+ Map<String, String> environment,
+ TestRunnerAction action,
+ Path execRoot,
+ Path runfilesDir)
+ throws ExecException, InterruptedException, IOException {
+ Executor executor = actionExecutionContext.getExecutor();
+ Closeable streamed = null;
+ Path testLogPath = action.getTestLog().getPath();
+ TestResultData.Builder builder = TestResultData.newBuilder();
+
+ Map<String, String> info = new HashMap<>();
+ // This key is only understood by StandaloneSpawnStrategy.
+ info.put("timeout", "" + getTimeout(action));
+ info.putAll(action.getTestProperties().getExecutionInfo());
+
+ Artifact testSetup = action.getRuntimeArtifact(TEST_SETUP_BASENAME);
+ TestRunnerAction.ResolvedPaths resolvedPaths = action.resolve(execRoot);
+ Spawn spawn =
+ new BaseSpawn(
+ getArgs(testSetup.getExecPathString(), COLLECT_COVERAGE, action),
+ environment,
+ info,
+ new RunfilesSupplierImpl(
+ runfilesDir.asFragment(), action.getExecutionSettings().getRunfiles()),
+ action,
+ action.getTestProperties().getLocalResourceUsage(executionOptions.usingLocalTestJobs()),
+ ImmutableSet.of(resolvedPaths.getXmlOutputPath().relativeTo(execRoot)));
+ long startTime = executor.getClock().currentTimeMillis();
+ SpawnActionContext spawnActionContext = executor.getSpawnActionContext(action.getMnemonic());
+ try {
+ try {
+ if (executionOptions.testOutput.equals(TestOutputFormat.STREAMED)) {
+ streamed =
+ new StreamedTestOutput(
+ Reporter.outErrForReporter(
+ actionExecutionContext.getExecutor().getEventHandler()),
+ testLogPath);
+ }
+ spawnActionContext.exec(spawn, actionExecutionContext);
+
+ builder
+ .setTestPassed(true)
+ .setStatus(BlazeTestStatus.PASSED)
+ .setCachable(true)
+ .setPassedLog(testLogPath.getPathString());
+ } catch (ExecException e) {
+ // Execution failed, which we consider a test failure.
+
+ // TODO(bazel-team): set cachable==true for relevant statuses (failure, but not for
+ // timeout, etc.)
+ builder
+ .setTestPassed(false)
+ .setStatus(e.hasTimedOut() ? BlazeTestStatus.TIMEOUT : BlazeTestStatus.FAILED)
+ .addFailedLogs(testLogPath.getPathString());
+ if (spawnActionContext.shouldPropagateExecException()) {
+ throw e;
+ }
+ } finally {
+ long duration = executor.getClock().currentTimeMillis() - startTime;
+ builder.addTestTimes(duration);
+ builder.setRunDurationMillis(duration);
+ if (streamed != null) {
+ streamed.close();
+ }
+ }
+
+ TestCase details =
+ parseTestResult(
+ action
+ .resolve(actionExecutionContext.getExecutor().getExecRoot())
+ .getXmlOutputPath());
+ if (details != null) {
+ builder.setTestCase(details);
+ }
+
+ if (isCoverageMode(action)) {
+ builder.setHasCoverage(true);
+ }
+
+ return builder.build();
+ } catch (IOException e) {
+ throw new TestExecException(e.getMessage());
+ }
+ }
+
+ /**
+ * Outputs test result to the stdout after test has finished (e.g. for --test_output=all or
+ * --test_output=errors). Will also try to group output lines together (up to 10000 lines) so
+ * parallel test outputs will not get interleaved.
+ */
+ protected void processTestOutput(Executor executor, FileOutErr outErr, TestResult result)
+ throws IOException {
+ Path testOutput = executor.getExecRoot().getRelative(result.getTestLogPath().asFragment());
+ boolean isPassed = result.getData().getTestPassed();
+ try {
+ if (TestLogHelper.shouldOutputTestLog(executionOptions.testOutput, isPassed)) {
+ TestLogHelper.writeTestLog(testOutput, result.getTestName(), outErr.getOutputStream());
+ }
+ } finally {
+ if (isPassed) {
+ executor.getEventHandler().handle(Event.of(EventKind.PASS, null, result.getTestName()));
+ } else {
+ if (result.getData().getStatus() == BlazeTestStatus.TIMEOUT) {
+ executor
+ .getEventHandler()
+ .handle(
+ Event.of(
+ EventKind.TIMEOUT, null, result.getTestName() + " (see " + testOutput + ")"));
+ } else {
+ executor
+ .getEventHandler()
+ .handle(
+ Event.of(
+ EventKind.FAIL, null, result.getTestName() + " (see " + testOutput + ")"));
+ }
+ }
+ }
+ }
+
+ private final void finalizeTest(
+ ActionExecutionContext actionExecutionContext, TestRunnerAction action, TestResultData data)
+ throws IOException, ExecException {
+ TestResult result = new TestResult(action, data, false);
+ postTestResult(actionExecutionContext.getExecutor(), result);
+
+ processTestOutput(
+ actionExecutionContext.getExecutor(), actionExecutionContext.getFileOutErr(), result);
+ // TODO(bazel-team): handle --test_output=errors, --test_output=all.
+
+ if (!executionOptions.testKeepGoing && data.getStatus() != BlazeTestStatus.PASSED) {
+ throw new TestExecException("Test failed: aborting");
+ }
+ }
+
+ @Override
+ public TestResult newCachedTestResult(
+ Path execRoot, TestRunnerAction action, TestResultData data) {
+ return new TestResult(action, data, /*cached*/ true);
+ }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/exec/TestLogHelper.java b/src/main/java/com/google/devtools/build/lib/exec/TestLogHelper.java
new file mode 100644
index 0000000..65d1d61
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/exec/TestLogHelper.java
@@ -0,0 +1,132 @@
+// Copyright 2014 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.exec;
+
+import com.google.common.io.ByteStreams;
+import com.google.devtools.build.lib.exec.TestStrategy.TestOutputFormat;
+import com.google.devtools.build.lib.vfs.Path;
+import java.io.BufferedOutputStream;
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.PrintStream;
+
+/**
+ * A helper class for test log handling. It determines whether the test log should be output and
+ * formats the test log for console display.
+ */
+public class TestLogHelper {
+
+ public static final String HEADER_DELIMITER =
+ "-----------------------------------------------------------------------------";
+
+ /**
+ * Determines whether the test log should be output from the current outputMode and whether the
+ * test has passed or not.
+ */
+ public static boolean shouldOutputTestLog(TestOutputFormat outputMode, boolean hasPassed) {
+ return (outputMode == TestOutputFormat.ALL)
+ || (!hasPassed && (outputMode == TestOutputFormat.ERRORS));
+ }
+
+ /**
+ * Reads the contents of the test log from the provided testOutput file, adds header and footer
+ * and returns the result. This method also looks for a header delimiter and cuts off the text
+ * before it, except if the header is 50 lines or longer.
+ */
+ public static void writeTestLog(Path testOutput, String testName, OutputStream out)
+ throws IOException {
+ InputStream input = null;
+ PrintStream printOut = new PrintStream(new BufferedOutputStream(out));
+ try {
+ final String outputHeader = "==================== Test output for " + testName + ":";
+ final String outputFooter =
+ "================================================================================";
+
+ printOut.println(outputHeader);
+ printOut.flush();
+
+ input = testOutput.getInputStream();
+ FilterTestHeaderOutputStream filteringOutputStream = getHeaderFilteringOutputStream(printOut);
+ ByteStreams.copy(input, filteringOutputStream);
+
+ if (!filteringOutputStream.foundHeader()) {
+ try (InputStream inputAgain = testOutput.getInputStream()) {
+ ByteStreams.copy(inputAgain, out);
+ }
+ }
+
+ printOut.println(outputFooter);
+ } finally {
+ printOut.flush();
+ if (input != null) {
+ input.close();
+ }
+ }
+ }
+
+ /**
+ * Returns an output stream that doesn't write to original until it sees HEADER_DELIMITER by
+ * itself on a line.
+ */
+ public static FilterTestHeaderOutputStream getHeaderFilteringOutputStream(OutputStream original) {
+ return new FilterTestHeaderOutputStream(original);
+ }
+
+ private TestLogHelper() {
+ // Prevent Java from creating a public constructor.
+ }
+
+ /** Use this class to filter the streaming output of a test until we see the header delimiter. */
+ public static class FilterTestHeaderOutputStream extends FilterOutputStream {
+
+ private boolean seenDelimiter = false;
+ private StringBuilder lineBuilder = new StringBuilder();
+
+ private static final int NEWLINE = '\n';
+
+ public FilterTestHeaderOutputStream(OutputStream out) {
+ super(out);
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ if (seenDelimiter) {
+ out.write(b);
+ } else if (b == NEWLINE) {
+ String line = lineBuilder.toString();
+ lineBuilder = new StringBuilder();
+ if (line.equals(TestLogHelper.HEADER_DELIMITER)) {
+ seenDelimiter = true;
+ }
+ } else if (lineBuilder.length() <= TestLogHelper.HEADER_DELIMITER.length()) {
+ lineBuilder.append((char) b);
+ }
+ }
+
+ @Override
+ public void write(byte b[], int off, int len) throws IOException {
+ if (seenDelimiter) {
+ out.write(b, off, len);
+ } else {
+ super.write(b, off, len);
+ }
+ }
+
+ public boolean foundHeader() {
+ return seenDelimiter;
+ }
+ }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/exec/TestStrategy.java b/src/main/java/com/google/devtools/build/lib/exec/TestStrategy.java
new file mode 100644
index 0000000..96d8798
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/exec/TestStrategy.java
@@ -0,0 +1,541 @@
+// Copyright 2014 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.devtools.build.lib.exec;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.io.ByteStreams;
+import com.google.common.io.Closeables;
+import com.google.devtools.build.lib.actions.ActionExecutionContext;
+import com.google.devtools.build.lib.actions.ExecException;
+import com.google.devtools.build.lib.actions.Executor;
+import com.google.devtools.build.lib.analysis.config.BinTools;
+import com.google.devtools.build.lib.events.Event;
+import com.google.devtools.build.lib.profiler.Profiler;
+import com.google.devtools.build.lib.profiler.ProfilerTask;
+import com.google.devtools.build.lib.rules.test.TestActionContext;
+import com.google.devtools.build.lib.rules.test.TestResult;
+import com.google.devtools.build.lib.rules.test.TestRunnerAction;
+import com.google.devtools.build.lib.rules.test.TestTargetExecutionSettings;
+import com.google.devtools.build.lib.util.OS;
+import com.google.devtools.build.lib.util.ShellEscaper;
+import com.google.devtools.build.lib.util.io.FileWatcher;
+import com.google.devtools.build.lib.util.io.OutErr;
+import com.google.devtools.build.lib.vfs.FileStatus;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.lib.vfs.SearchPath;
+import com.google.devtools.build.lib.view.test.TestStatus.TestCase;
+import com.google.devtools.common.options.Converters.RangeConverter;
+import com.google.devtools.common.options.EnumConverter;
+import com.google.devtools.common.options.OptionsClassProvider;
+import com.google.devtools.common.options.OptionsParsingException;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+
+/** A strategy for executing a {@link TestRunnerAction}. */
+public abstract class TestStrategy implements TestActionContext {
+ public static final PathFragment COVERAGE_TMP_ROOT = new PathFragment("_coverage");
+
+ public static final String TEST_SETUP_BASENAME = "test-setup.sh";
+
+ /** Returns true if coverage data should be gathered. */
+ protected static boolean isCoverageMode(TestRunnerAction action) {
+ return action.getCoverageData() != null;
+ }
+
+ /**
+ * Ensures that all directories used to run test are in the correct state and their content will
+ * not result in stale files.
+ */
+ protected void prepareFileSystem(
+ TestRunnerAction testAction, Path tmpDir, Path coverageDir, Path workingDirectory)
+ throws IOException {
+ if (isCoverageMode(testAction)) {
+ recreateDirectory(coverageDir);
+ }
+ recreateDirectory(tmpDir);
+ FileSystemUtils.createDirectoryAndParents(workingDirectory);
+ }
+
+ /** Removes directory if it exists and recreates it. */
+ protected void recreateDirectory(Path directory) throws IOException {
+ FileSystemUtils.deleteTree(directory);
+ FileSystemUtils.createDirectoryAndParents(directory);
+ }
+
+ /** Converter for the --flaky_test_attempts option. */
+ public static class TestAttemptsConverter extends RangeConverter {
+ public TestAttemptsConverter() {
+ super(1, 10);
+ }
+
+ @Override
+ public Integer convert(String input) throws OptionsParsingException {
+ if ("default".equals(input)) {
+ return -1;
+ } else {
+ return super.convert(input);
+ }
+ }
+
+ @Override
+ public String getTypeDescription() {
+ return super.getTypeDescription() + " or the string \"default\"";
+ }
+ }
+
+ public enum TestOutputFormat {
+ SUMMARY, // Provide summary output only.
+ ERRORS, // Print output from failed tests to the stderr after the test failure.
+ ALL, // Print output from all tests to the stderr after the test completion.
+ STREAMED; // Stream output for each test.
+
+ /** Converts to {@link TestOutputFormat}. */
+ public static class Converter extends EnumConverter<TestOutputFormat> {
+ public Converter() {
+ super(TestOutputFormat.class, "test output");
+ }
+ }
+ }
+
+ public enum TestSummaryFormat {
+ SHORT, // Print information only about tests.
+ TERSE, // Like "SHORT", but even shorter: Do not print PASSED tests.
+ DETAILED, // Print information only about failed test cases.
+ NONE; // Do not print summary.
+
+ /** Converts to {@link TestSummaryFormat}. */
+ public static class Converter extends EnumConverter<TestSummaryFormat> {
+ public Converter() {
+ super(TestSummaryFormat.class, "test summary");
+ }
+ }
+ }
+
+ public static final PathFragment TEST_TMP_ROOT = new PathFragment("_tmp");
+
+ // Used for selecting subset of testcase / testmethods.
+ private static final String TEST_BRIDGE_TEST_FILTER_ENV = "TESTBRIDGE_TEST_ONLY";
+
+ // Used for generating unique temporary directory names. Contains the next numeric index for every
+ // executable base name.
+ private final Map<String, Integer> tmpIndex = new HashMap<>();
+ protected final ImmutableMap<String, String> clientEnv;
+ protected final ExecutionOptions executionOptions;
+ protected final BinTools binTools;
+
+ public TestStrategy(
+ OptionsClassProvider requestOptionsProvider,
+ BinTools binTools,
+ Map<String, String> clientEnv) {
+ this.executionOptions = requestOptionsProvider.getOptions(ExecutionOptions.class);
+ this.binTools = binTools;
+ this.clientEnv = ImmutableMap.copyOf(clientEnv);
+ }
+
+ @Override
+ public abstract void exec(TestRunnerAction action, ActionExecutionContext actionExecutionContext)
+ throws ExecException, InterruptedException;
+
+ /** Returns true if coverage data should be gathered. */
+ protected static boolean isMicroCoverageMode(TestRunnerAction action) {
+ return action.getMicroCoverageData() != null;
+ }
+
+ /**
+ * Returns directory to store coverage results for the given action relative to the execution
+ * root. This directory is used to store all coverage results related to the test execution with
+ * exception of the locally generated *.gcda files. Those are stored separately using relative
+ * path within coverage directory.
+ *
+ * <p>Coverage directory name for the given test runner action is constructed as: {@code $(blaze
+ * info execution_root)/_coverage/target_path/test_log_name} where {@code test_log_name} is
+ * usually a target name but potentially can include extra suffix, such as a shard number (if test
+ * execution was sharded).
+ */
+ protected static PathFragment getCoverageDirectory(TestRunnerAction action) {
+ return COVERAGE_TMP_ROOT.getRelative(
+ FileSystemUtils.removeExtension(action.getTestLog().getRootRelativePath()));
+ }
+
+ /**
+ * Returns mutable map of default testing shell environment. By itself it is incomplete and is
+ * modified further by the specific test strategy implementations (mostly due to the fact that
+ * environments used locally and remotely are different).
+ */
+ protected Map<String, String> getDefaultTestEnvironment(TestRunnerAction action) {
+ Map<String, String> env = new HashMap<>();
+
+ env.putAll(action.getConfiguration().getLocalShellEnvironment());
+ env.remove("LANG");
+ env.put("TZ", "UTC");
+ env.put("TEST_SIZE", action.getTestProperties().getSize().toString());
+ env.put("TEST_TIMEOUT", Integer.toString(getTimeout(action)));
+
+ if (action.isSharded()) {
+ env.put("TEST_SHARD_INDEX", Integer.toString(action.getShardNum()));
+ env.put(
+ "TEST_TOTAL_SHARDS", Integer.toString(action.getExecutionSettings().getTotalShards()));
+ }
+
+ // When we run test multiple times, set different TEST_RANDOM_SEED values for each run.
+ if (action.getConfiguration().getRunsPerTestForLabel(action.getOwner().getLabel()) > 1) {
+ env.put("TEST_RANDOM_SEED", Integer.toString(action.getRunNumber() + 1));
+ }
+
+ String testFilter = action.getExecutionSettings().getTestFilter();
+ if (testFilter != null) {
+ env.put(TEST_BRIDGE_TEST_FILTER_ENV, testFilter);
+ }
+
+ if (isCoverageMode(action)) {
+ env.put(
+ "COVERAGE_MANIFEST",
+ action.getExecutionSettings().getInstrumentedFileManifest().getExecPathString());
+ // Instruct remote-runtest.sh/local-runtest.sh not to cd into the runfiles directory.
+ env.put("RUNTEST_PRESERVE_CWD", "1");
+ env.put("MICROCOVERAGE_REQUESTED", isMicroCoverageMode(action) ? "true" : "false");
+ }
+
+ return env;
+ }
+
+ /**
+ * Generates a command line to run for the test action, taking into account coverage and {@code
+ * --run_under} settings.
+ *
+ * @param testScript the setup script that invokes the test
+ * @param coverageScript a script interjected between setup script and rest of command line to
+ * collect coverage data. If this is an empty string, it is ignored.
+ * @param testAction The test action.
+ * @return the command line as string list.
+ */
+ protected List<String> getArgs(
+ String testScript, String coverageScript, TestRunnerAction testAction) {
+ List<String> args = Lists.newArrayList();
+ if (OS.getCurrent() == OS.WINDOWS) {
+ args.add(testAction.getShExecutable().getPathString());
+ args.add("-c");
+ args.add("$0 $*");
+ }
+ args.add(testScript);
+ TestTargetExecutionSettings execSettings = testAction.getExecutionSettings();
+
+ List<String> execArgs = new ArrayList<>();
+ if (!coverageScript.isEmpty() && isCoverageMode(testAction)) {
+ execArgs.add(coverageScript);
+ }
+
+ // Execute the test using the alias in the runfiles tree, as mandated by
+ // the Test Encyclopedia.
+ execArgs.add(execSettings.getExecutable().getRootRelativePath().getPathString());
+ execArgs.addAll(execSettings.getArgs());
+
+ // Insert the command prefix specified by the "--run_under=<command-prefix>" option,
+ // if any.
+ if (execSettings.getRunUnder() == null) {
+ args.addAll(execArgs);
+ } else if (execSettings.getRunUnderExecutable() != null) {
+ args.add(execSettings.getRunUnderExecutable().getRootRelativePath().getPathString());
+ args.addAll(execSettings.getRunUnder().getOptions());
+ args.addAll(execArgs);
+ } else {
+ args.add(testAction.getConfiguration().getShellExecutable().getPathString());
+ args.add("-c");
+
+ String runUnderCommand = ShellEscaper.escapeString(execSettings.getRunUnder().getCommand());
+
+ Path fullySpecified =
+ SearchPath.which(
+ SearchPath.parse(
+ testAction.getTestLog().getPath().getFileSystem(), clientEnv.get("PATH")),
+ runUnderCommand);
+
+ if (fullySpecified != null) {
+ runUnderCommand = fullySpecified.toString();
+ }
+
+ args.add(
+ runUnderCommand
+ + ' '
+ + ShellEscaper.escapeJoinAll(
+ Iterables.concat(execSettings.getRunUnder().getOptions(), execArgs)));
+ }
+ return args;
+ }
+
+ /**
+ * Returns the number of attempts specific test action can be retried.
+ *
+ * <p>For rules with "flaky = 1" attribute, this method will return 3 unless --flaky_test_attempts
+ * option is given and specifies another value.
+ */
+ @VisibleForTesting /* protected */
+ public int getTestAttempts(TestRunnerAction action) {
+ if (executionOptions.testAttempts == -1) {
+ return action.getTestProperties().isFlaky() ? 3 : 1;
+ } else {
+ return executionOptions.testAttempts;
+ }
+ }
+
+ /**
+ * Returns timeout value in seconds that should be used for the given test action. We always use
+ * the "categorical timeouts" which are based on the --test_timeout flag. A rule picks its timeout
+ * but ends up with the same effective value as all other rules in that bucket.
+ */
+ protected final int getTimeout(TestRunnerAction testAction) {
+ return executionOptions.testTimeout.get(testAction.getTestProperties().getTimeout());
+ }
+
+ /**
+ * Returns a subset of the environment from the current shell.
+ *
+ * <p>Warning: Since these variables are not part of the configuration's fingerprint, they MUST
+ * NOT be used by any rule or action in such a way as to affect the semantics of that build step.
+ */
+ public Map<String, String> getAdmissibleShellEnvironment(Iterable<String> variables) {
+ return getMapping(variables, clientEnv);
+ }
+
+ /*
+ * Finalize test run: persist the result, and post on the event bus.
+ */
+ protected void postTestResult(Executor executor, TestResult result) throws IOException {
+ result.getTestAction().saveCacheStatus(result.getData());
+ executor.getEventBus().post(result);
+ }
+
+ /**
+ * Returns a unique name for a temporary directory a test could use.
+ *
+ * <p>Since each test within single Blaze run must have a unique TEST_TMPDIR, we will use rule
+ * name and a unique (within single Blaze request) number to generate directory name.
+ *
+ * <p>This does not create the directory.
+ */
+ protected String getTmpDirName(PathFragment execPath) {
+ String basename = execPath.getBaseName();
+
+ synchronized (tmpIndex) {
+ int index = tmpIndex.containsKey(basename) ? tmpIndex.get(basename) : 1;
+ tmpIndex.put(basename, index + 1);
+ return basename + "_" + index;
+ }
+ }
+
+ /** Parse a test result XML file into a {@link TestCase}. */
+ @Nullable
+ protected TestCase parseTestResult(Path resultFile) {
+ /* xml files. We avoid parsing it unnecessarily, since test results can potentially consume
+ a large amount of memory. */
+ if (executionOptions.testSummary != TestSummaryFormat.DETAILED) {
+ return null;
+ }
+
+ try (InputStream fileStream = resultFile.getInputStream()) {
+ return new TestXmlOutputParser().parseXmlIntoTestResult(fileStream);
+ } catch (IOException | TestXmlOutputParserException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Returns a temporary directory for all tests in a workspace to use. Individual tests should
+ * create child directories to actually use.
+ *
+ * <p>This either dynamically generates a directory name or uses the directory specified by
+ * --test_tmpdir. This does not create the directory.
+ */
+ public static Path getTmpRoot(Path workspace, Path execRoot, ExecutionOptions executionOptions) {
+ return executionOptions.testTmpDir != null
+ ? workspace.getRelative(executionOptions.testTmpDir).getRelative(TEST_TMP_ROOT)
+ : execRoot.getRelative(TEST_TMP_ROOT);
+ }
+
+ /**
+ * For an given environment, returns a subset containing all variables in the given list if they
+ * are defined in the given environment.
+ */
+ @VisibleForTesting
+ public static Map<String, String> getMapping(
+ Iterable<String> variables, Map<String, String> environment) {
+ Map<String, String> result = new HashMap<>();
+ for (String var : variables) {
+ if (environment.containsKey(var)) {
+ result.put(var, environment.get(var));
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Returns the runfiles directory associated with the test executable, creating/updating it if
+ * necessary and --build_runfile_links is specified.
+ */
+ protected static Path getLocalRunfilesDirectory(
+ TestRunnerAction testAction,
+ ActionExecutionContext actionExecutionContext,
+ BinTools binTools,
+ ImmutableMap<String, String> shellEnvironment,
+ boolean enableRunfiles)
+ throws ExecException, InterruptedException {
+ TestTargetExecutionSettings execSettings = testAction.getExecutionSettings();
+
+ // If the symlink farm is already created then return the existing directory. If not we
+ // need to explicitly build it. This can happen when --nobuild_runfile_links is supplied
+ // as a flag to the build.
+ if (execSettings.getRunfilesSymlinksCreated()) {
+ return execSettings.getRunfilesDir();
+ }
+
+ // TODO(bazel-team): Should we be using TestTargetExecutionSettings#getRunfilesDir() here over
+ // generating the directory ourselves?
+ Path program = execSettings.getExecutable().getPath();
+ Path runfilesDir = program.getParentDirectory().getChild(program.getBaseName() + ".runfiles");
+
+ // Synchronize runfiles tree generation on the runfiles manifest artifact.
+ // This is necessary, because we might end up with multiple test runner actions
+ // trying to generate same runfiles tree in case of --runs_per_test > 1 or
+ // local test sharding.
+ long startTime = Profiler.nanoTimeMaybe();
+ synchronized (execSettings.getInputManifest()) {
+ Profiler.instance().logSimpleTask(startTime, ProfilerTask.WAIT, testAction);
+ updateLocalRunfilesDirectory(
+ testAction,
+ runfilesDir,
+ actionExecutionContext,
+ binTools,
+ shellEnvironment,
+ enableRunfiles);
+ }
+
+ return runfilesDir;
+ }
+
+ /**
+ * Ensure the runfiles tree exists and is consistent with the TestAction's manifest
+ * ($0.runfiles_manifest), bringing it into consistency if not. The contents of the output file
+ * $0.runfiles/MANIFEST, if it exists, are used a proxy for the set of existing symlinks, to avoid
+ * the need for recursion.
+ */
+ private static void updateLocalRunfilesDirectory(
+ TestRunnerAction testAction,
+ Path runfilesDir,
+ ActionExecutionContext actionExecutionContext,
+ BinTools binTools,
+ ImmutableMap<String, String> shellEnvironment,
+ boolean enableRunfiles)
+ throws ExecException, InterruptedException {
+ Executor executor = actionExecutionContext.getExecutor();
+
+ TestTargetExecutionSettings execSettings = testAction.getExecutionSettings();
+ try {
+ // Avoid rebuilding the runfiles directory if the manifest in it matches the input manifest,
+ // implying the symlinks exist and are already up to date.
+ if (Arrays.equals(
+ runfilesDir.getRelative("MANIFEST").getDigest(),
+ execSettings.getInputManifest().getPath().getDigest())) {
+ return;
+ }
+ } catch (IOException e1) {
+ // Ignore it - we will just try to create runfiles directory.
+ }
+
+ executor
+ .getEventHandler()
+ .handle(
+ Event.progress(
+ "Building runfiles directory for '"
+ + execSettings.getExecutable().prettyPrint()
+ + "'."));
+
+ new SymlinkTreeHelper(execSettings.getInputManifest().getPath(), runfilesDir, false)
+ .createSymlinks(
+ testAction, actionExecutionContext, binTools, shellEnvironment, enableRunfiles);
+
+ executor.getEventHandler().handle(Event.progress(testAction.getProgressMessage()));
+ }
+
+ /** In rare cases, we might write something to stderr. Append it to the real test.log. */
+ protected static void appendStderr(Path stdOut, Path stdErr) throws IOException {
+ FileStatus stat = stdErr.statNullable();
+ OutputStream out = null;
+ InputStream in = null;
+ if (stat != null) {
+ try {
+ if (stat.getSize() > 0) {
+ if (stdOut.exists()) {
+ stdOut.setWritable(true);
+ }
+ out = stdOut.getOutputStream(true);
+ in = stdErr.getInputStream();
+ ByteStreams.copy(in, out);
+ }
+ } finally {
+ Closeables.close(out, true);
+ Closeables.close(in, true);
+ stdErr.delete();
+ }
+ }
+ }
+
+ /** Implements the --test_output=streamed option. */
+ protected static class StreamedTestOutput implements Closeable {
+ private final TestLogHelper.FilterTestHeaderOutputStream headerFilter;
+ private final FileWatcher watcher;
+ private final Path testLogPath;
+ private final OutErr outErr;
+
+ public StreamedTestOutput(OutErr outErr, Path testLogPath) throws IOException {
+ this.testLogPath = testLogPath;
+ this.outErr = outErr;
+ this.headerFilter = TestLogHelper.getHeaderFilteringOutputStream(outErr.getOutputStream());
+ this.watcher = new FileWatcher(testLogPath, OutErr.create(headerFilter, headerFilter), false);
+ watcher.start();
+ }
+
+ @Override
+ public void close() throws IOException {
+ watcher.stopPumping();
+ try {
+ // The watcher thread might leak if the following call is interrupted.
+ // This is a relatively minor issue since the worst it could do is
+ // write one additional line from the test.log to the console later on
+ // in the build.
+ watcher.join();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ if (!headerFilter.foundHeader()) {
+ try (InputStream input = testLogPath.getInputStream()) {
+ ByteStreams.copy(input, outErr.getOutputStream());
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/exec/TestXmlOutputParser.java b/src/main/java/com/google/devtools/build/lib/exec/TestXmlOutputParser.java
new file mode 100644
index 0000000..277a87d
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/exec/TestXmlOutputParser.java
@@ -0,0 +1,352 @@
+// Copyright 2014 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.devtools.build.lib.exec;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.devtools.build.lib.view.test.TestStatus.TestCase;
+import com.google.devtools.build.lib.view.test.TestStatus.TestCase.Type;
+import com.google.protobuf.UninitializedMessageException;
+import java.io.InputStream;
+import java.util.Collection;
+import javax.xml.stream.XMLInputFactory;
+import javax.xml.stream.XMLStreamConstants;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+
+/**
+ * Parses a test.xml generated by jUnit or any testing framework into a protocol buffer. The schema
+ * of the test.xml is a bit hazy, so there is some guesswork involved.
+ */
+final class TestXmlOutputParser {
+ // jUnit can use either "testsuites" or "testsuite".
+ private static final Collection<String> TOPLEVEL_ELEMENT_NAMES =
+ ImmutableSet.of("testsuites", "testsuite");
+
+ public TestCase parseXmlIntoTestResult(InputStream xmlStream)
+ throws TestXmlOutputParserException {
+ return parseXmlToTree(xmlStream);
+ }
+
+ /**
+ * Parses the a test result XML file into the corresponding protocol buffer.
+ *
+ * @param xmlStream the XML data stream
+ * @return the protocol buffer with the parsed data, or null if there was an error while parsing
+ * the file.
+ * @throws TestXmlOutputParserException when the XML file cannot be parsed
+ */
+ private TestCase parseXmlToTree(InputStream xmlStream) throws TestXmlOutputParserException {
+ XMLStreamReader parser = null;
+
+ try {
+ parser = XMLInputFactory.newInstance().createXMLStreamReader(xmlStream);
+
+ while (true) {
+ int event = parser.next();
+ if (event == XMLStreamConstants.END_DOCUMENT) {
+ return null;
+ }
+
+ // First find the topmost node.
+ if (event == XMLStreamConstants.START_ELEMENT) {
+ String elementName = parser.getLocalName();
+ if (TOPLEVEL_ELEMENT_NAMES.contains(elementName)) {
+ TestCase result = parseTestSuite(parser, elementName);
+ return result;
+ }
+ }
+ }
+ } catch (XMLStreamException e) {
+ throw new TestXmlOutputParserException(e);
+ } catch (NumberFormatException e) {
+ // The parser is definitely != null here.
+ throw new TestXmlOutputParserException(
+ "Number could not be parsed at "
+ + parser.getLocation().getLineNumber()
+ + ":"
+ + parser.getLocation().getColumnNumber(),
+ e);
+ } catch (UninitializedMessageException e) {
+ // This happens when the XML does not contain a field that is required
+ // in the protocol buffer
+ throw new TestXmlOutputParserException(e);
+ } catch (RuntimeException e) {
+
+ // Seems like that an XNIException can leak through, even though it is not
+ // specified anywhere.
+ //
+ // It's a bad idea to refer to XNIException directly because the Xerces
+ // documentation says that it may not be available here soon (and it
+ // results in a compile-time warning anyway), so we do it the roundabout
+ // way: check if the class name has something to do with Xerces, and if
+ // so, wrap it in our own exception type, otherwise, let the stack
+ // unwinding continue.
+ String name = e.getClass().getCanonicalName();
+ if (name != null && name.contains("org.apache.xerces")) {
+ throw new TestXmlOutputParserException(e);
+ } else {
+ throw e;
+ }
+ } finally {
+ if (parser != null) {
+ try {
+ parser.close();
+ } catch (XMLStreamException e) {
+
+ // Ignore errors during closure so that we do not interfere with an
+ // already propagating exception.
+ }
+ }
+ }
+ }
+
+ /**
+ * Creates an exception suitable to be thrown when and a bad end tag appears. The exception could
+ * also be thrown from here but that would result in an extra stack frame, whereas this way, the
+ * topmost frame shows the location where the error occurred.
+ */
+ private TestXmlOutputParserException createBadElementException(
+ String expected, XMLStreamReader parser) {
+ return new TestXmlOutputParserException(
+ "Expected end of XML element '"
+ + expected
+ + "' , but got '"
+ + parser.getLocalName()
+ + "' at "
+ + parser.getLocation().getLineNumber()
+ + ":"
+ + parser.getLocation().getColumnNumber());
+ }
+
+ /**
+ * Parses a 'testsuite' element.
+ *
+ * @throws TestXmlOutputParserException if the XML document is malformed
+ * @throws XMLStreamException if there was an error processing the XML
+ * @throws NumberFormatException if one of the numeric fields does not contain a valid number
+ */
+ private TestCase parseTestSuite(XMLStreamReader parser, String elementName)
+ throws XMLStreamException, TestXmlOutputParserException {
+ TestCase.Builder builder = TestCase.newBuilder();
+ builder.setType(Type.TEST_SUITE);
+ for (int i = 0; i < parser.getAttributeCount(); i++) {
+ String name = parser.getAttributeLocalName(i).intern();
+ String value = parser.getAttributeValue(i);
+
+ if (name.equals("name")) {
+ builder.setName(value);
+ } else if (name.equals("time")) {
+ builder.setRunDurationMillis(parseTime(value));
+ }
+ }
+
+ parseContainedElements(parser, elementName, builder);
+ return builder.build();
+ }
+
+ /**
+ * Parses a time in test.xml format.
+ *
+ * @throws NumberFormatException if the time is malformed (i.e. is neither an integer nor a
+ * decimal fraction with '.' as the fraction separator)
+ */
+ private long parseTime(String string) {
+
+ // This is ugly. For Historical Reasons, we have to check whether the number
+ // contains a decimal point or not. If it does, the number is expressed in
+ // milliseconds, otherwise, in seconds.
+ if (string.contains(".")) {
+ return Math.round(Float.parseFloat(string) * 1000);
+ } else {
+ return Long.parseLong(string);
+ }
+ }
+
+ /**
+ * Parses a 'decorator' element.
+ *
+ * @throws TestXmlOutputParserException if the XML document is malformed
+ * @throws XMLStreamException if there was an error processing the XML
+ * @throws NumberFormatException if one of the numeric fields does not contain a valid number
+ */
+ private TestCase parseTestDecorator(XMLStreamReader parser)
+ throws XMLStreamException, TestXmlOutputParserException {
+ TestCase.Builder builder = TestCase.newBuilder();
+ builder.setType(Type.TEST_DECORATOR);
+ for (int i = 0; i < parser.getAttributeCount(); i++) {
+ String name = parser.getAttributeLocalName(i);
+ String value = parser.getAttributeValue(i);
+
+ builder.setName(name);
+ if (name.equals("classname")) {
+ builder.setClassName(value);
+ } else if (name.equals("time")) {
+ builder.setRunDurationMillis(parseTime(value));
+ }
+ }
+
+ parseContainedElements(parser, "testdecorator", builder);
+ return builder.build();
+ }
+
+ /**
+ * Parses child elements of the specified tag. Strictly speaking, not every element can be a child
+ * of every other, but the HierarchicalTestResult can handle that, and (in this case) it does not
+ * hurt to be a bit more flexible than necessary.
+ *
+ * @throws TestXmlOutputParserException if the XML document is malformed
+ * @throws XMLStreamException if there was an error processing the XML
+ * @throws NumberFormatException if one of the numeric fields does not contain a valid number
+ */
+ private void parseContainedElements(
+ XMLStreamReader parser, String elementName, TestCase.Builder builder)
+ throws XMLStreamException, TestXmlOutputParserException {
+ int failures = 0;
+ int errors = 0;
+
+ while (true) {
+ int event = parser.next();
+ switch (event) {
+ case XMLStreamConstants.START_ELEMENT:
+ String childElementName = parser.getLocalName().intern();
+
+ // We are not parsing four elements here: system-out, system-err,
+ // failure and error. They potentially contain useful information, but
+ // they can be too big to fit in the memory. We add failure and error
+ // elements to the output without a message, so that there is a
+ // difference between passed and failed test cases.
+ switch (childElementName) {
+ case "testsuite":
+ builder.addChild(parseTestSuite(parser, childElementName));
+ break;
+ case "testcase":
+ builder.addChild(parseTestCase(parser));
+ break;
+ case "failure":
+ failures += 1;
+ skipCompleteElement(parser);
+ break;
+ case "error":
+ errors += 1;
+ skipCompleteElement(parser);
+ break;
+ case "testdecorator":
+ builder.addChild(parseTestDecorator(parser));
+ break;
+ default:
+ // Unknown element encountered. Since the schema of the input file
+ // is a bit hazy, just skip it and go merrily on our way. Ignorance
+ // is bliss.
+ skipCompleteElement(parser);
+ }
+ break;
+
+ case XMLStreamConstants.END_ELEMENT:
+ // Propagate errors/failures from children up to the current case
+ for (int i = 0; i < builder.getChildCount(); i += 1) {
+ if (builder.getChild(i).getStatus() == TestCase.Status.ERROR) {
+ errors += 1;
+ }
+ if (builder.getChild(i).getStatus() == TestCase.Status.FAILED) {
+ failures += 1;
+ }
+ }
+
+ if (errors > 0) {
+ builder.setStatus(TestCase.Status.ERROR);
+ } else if (failures > 0) {
+ builder.setStatus(TestCase.Status.FAILED);
+ } else {
+ builder.setStatus(TestCase.Status.PASSED);
+ }
+ // This is the end tag of the element we are supposed to parse.
+ // Hooray, tell our superiors that our mission is complete.
+ if (!parser.getLocalName().equals(elementName)) {
+ throw createBadElementException(elementName, parser);
+ }
+ return;
+ }
+ }
+ }
+
+ /**
+ * Parses a 'testcase' element.
+ *
+ * @throws TestXmlOutputParserException if the XML document is malformed
+ * @throws XMLStreamException if there was an error processing the XML
+ * @throws NumberFormatException if the time field does not contain a valid number
+ */
+ private TestCase parseTestCase(XMLStreamReader parser)
+ throws XMLStreamException, TestXmlOutputParserException {
+ TestCase.Builder builder = TestCase.newBuilder();
+ builder.setType(Type.TEST_CASE);
+ for (int i = 0; i < parser.getAttributeCount(); i++) {
+ String name = parser.getAttributeLocalName(i).intern();
+ String value = parser.getAttributeValue(i);
+
+ switch (name) {
+ case "name":
+ builder.setName(value);
+ break;
+ case "classname":
+ builder.setClassName(value);
+ break;
+ case "time":
+ builder.setRunDurationMillis(parseTime(value));
+ break;
+ case "result":
+ builder.setResult(value);
+ break;
+ case "status":
+ if (value.equals("notrun")) {
+ builder.setRun(false);
+ } else if (value.equals("run")) {
+ builder.setRun(true);
+ }
+ break;
+ default:
+ // fall through
+ }
+ }
+
+ parseContainedElements(parser, "testcase", builder);
+ return builder.build();
+ }
+
+ /**
+ * Skips over a complete XML element on the input. Precondition: the cursor is at a START_ELEMENT.
+ * Postcondition: the cursor is at an END_ELEMENT.
+ *
+ * @throws XMLStreamException if the XML is malformed
+ */
+ private void skipCompleteElement(XMLStreamReader parser) throws XMLStreamException {
+ int depth = 1;
+ while (true) {
+ int event = parser.next();
+
+ switch (event) {
+ case XMLStreamConstants.START_ELEMENT:
+ depth++;
+ break;
+
+ case XMLStreamConstants.END_ELEMENT:
+ if (--depth == 0) {
+ return;
+ }
+ break;
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/exec/TestXmlOutputParserException.java b/src/main/java/com/google/devtools/build/lib/exec/TestXmlOutputParserException.java
new file mode 100644
index 0000000..ca5b274
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/exec/TestXmlOutputParserException.java
@@ -0,0 +1,30 @@
+// Copyright 2014 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.devtools.build.lib.exec;
+
+/** This exception gets thrown if there was a problem with parsing a test.xml file. */
+final class TestXmlOutputParserException extends Exception {
+ public TestXmlOutputParserException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public TestXmlOutputParserException(Throwable cause) {
+ super(cause);
+ }
+
+ public TestXmlOutputParserException(String message) {
+ super(message);
+ }
+}